Update: 2026-05-03 17:32:57
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?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'; }
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public static function log(
|
||||
string $action,
|
||||
?string $tenantId = null,
|
||||
?string $userId = null,
|
||||
?string $entityType = null,
|
||||
?string $entityId = null,
|
||||
?array $oldData = null,
|
||||
?array $newData = null,
|
||||
?array $metadata = null
|
||||
): void {
|
||||
try {
|
||||
$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, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())");
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$userId,
|
||||
$action,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$oldData ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null,
|
||||
$newData ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
$_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
$metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Audit] Failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?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"]);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services\JoFotara;
|
||||
|
||||
/**
|
||||
* UBLGeneratorService
|
||||
*
|
||||
* Generates UBL 2.1 compliant XML using DOMDocument for precise namespace control.
|
||||
*/
|
||||
final class UBLGeneratorService
|
||||
{
|
||||
public function generate(array $invoice, array $lines, array $company): string
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$root = $dom->createElementNS('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', 'Invoice');
|
||||
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cac', 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2');
|
||||
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2');
|
||||
$dom->appendChild($root);
|
||||
|
||||
// 1. Basic Information
|
||||
$root->appendChild($dom->createElement('cbc:UBLVersionID', '2.1'));
|
||||
$root->appendChild($dom->createElement('cbc:CustomizationID', 'TRADACO-2.1'));
|
||||
$root->appendChild($dom->createElement('cbc:ProfileID', 'reporting:1.0'));
|
||||
$root->appendChild($dom->createElement('cbc:ID', $invoice['invoice_number']));
|
||||
$root->appendChild($dom->createElement('cbc:IssueDate', $invoice['invoice_date']));
|
||||
|
||||
$typeCode = $dom->createElement('cbc:InvoiceTypeCode', $invoice['ubl_type_code'] ?? '388');
|
||||
$typeCode->setAttribute('name', $invoice['invoice_category'] ?? '01');
|
||||
$root->appendChild($typeCode);
|
||||
|
||||
$root->appendChild($dom->createElement('cbc:DocumentCurrencyCode', 'JOD'));
|
||||
$root->appendChild($dom->createElement('cbc:TaxCurrencyCode', 'JOD'));
|
||||
|
||||
// 2. AccountingSupplierParty
|
||||
$supplierParty = $dom->createElement('cac:AccountingSupplierParty');
|
||||
$party = $dom->createElement('cac:Party');
|
||||
|
||||
$partyId = $dom->createElement('cac:PartyIdentification');
|
||||
$idNode = $dom->createElement('cbc:ID', $company['tax_identification_number']);
|
||||
$idNode->setAttribute('schemeID', 'TN');
|
||||
$partyId->appendChild($idNode);
|
||||
$party->appendChild($partyId);
|
||||
|
||||
$partyName = $dom->createElement('cac:PartyName');
|
||||
$partyName->appendChild($dom->createElement('cbc:Name', $company['name']));
|
||||
$party->appendChild($partyName);
|
||||
|
||||
$addr = $dom->createElement('cac:PostalAddress');
|
||||
$addr->appendChild($dom->createElement('cbc:CityName', $company['city'] ?? 'Amman'));
|
||||
$country = $dom->createElement('cac:Country');
|
||||
$country->appendChild($dom->createElement('cbc:IdentificationCode', 'JO'));
|
||||
$addr->appendChild($country);
|
||||
$party->appendChild($addr);
|
||||
|
||||
$taxScheme = $dom->createElement('cac:PartyTaxScheme');
|
||||
$taxScheme->appendChild($dom->createElement('cbc:RegistrationName', $company['name']));
|
||||
$taxScheme->appendChild($dom->createElement('cbc:CompanyID', $company['tax_identification_number']));
|
||||
$ts = $dom->createElement('cac:TaxScheme');
|
||||
$ts->appendChild($dom->createElement('cbc:ID', 'VAT'));
|
||||
$taxScheme->appendChild($ts);
|
||||
$party->appendChild($taxScheme);
|
||||
|
||||
$legalEntity = $dom->createElement('cac:PartyLegalEntity');
|
||||
$legalEntity->appendChild($dom->createElement('cbc:RegistrationName', $company['name']));
|
||||
$party->appendChild($legalEntity);
|
||||
|
||||
$supplierParty->appendChild($party);
|
||||
$root->appendChild($supplierParty);
|
||||
|
||||
// 3. AccountingCustomerParty
|
||||
$customerParty = $dom->createElement('cac:AccountingCustomerParty');
|
||||
$cParty = $dom->createElement('cac:Party');
|
||||
|
||||
$cName = $dom->createElement('cac:PartyName');
|
||||
$cName->appendChild($dom->createElement('cbc:Name', $invoice['buyer_name'] ?? 'عميل عام'));
|
||||
$cParty->appendChild($cName);
|
||||
|
||||
if (!empty($invoice['buyer_tin'])) {
|
||||
$cId = $dom->createElement('cac:PartyIdentification');
|
||||
$cidNode = $dom->createElement('cbc:ID', $invoice['buyer_tin']);
|
||||
$cidNode->setAttribute('schemeID', 'TN');
|
||||
$cId->appendChild($cidNode);
|
||||
$cParty->appendChild($cId);
|
||||
}
|
||||
|
||||
$customerParty->appendChild($cParty);
|
||||
$root->appendChild($customerParty);
|
||||
|
||||
// 4. PaymentMeans
|
||||
$paymentMeans = $dom->createElement('cac:PaymentMeans');
|
||||
$paymentMeans->appendChild($dom->createElement('cbc:PaymentMeansCode', $invoice['payment_method_code'] ?? '10'));
|
||||
$root->appendChild($paymentMeans);
|
||||
|
||||
// 5. TaxTotal
|
||||
$taxTotal = $dom->createElement('cac:TaxTotal');
|
||||
$taxAmt = $dom->createElement('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''));
|
||||
$taxAmt->setAttribute('currencyID', 'JOD');
|
||||
$taxTotal->appendChild($taxAmt);
|
||||
$root->appendChild($taxTotal);
|
||||
|
||||
// 6. LegalMonetaryTotal
|
||||
$monetaryTotal = $dom->createElement('cac:LegalMonetaryTotal');
|
||||
$fields = [
|
||||
'LineExtensionAmount' => $invoice['subtotal'],
|
||||
'TaxExclusiveAmount' => $invoice['subtotal'],
|
||||
'TaxInclusiveAmount' => $invoice['grand_total'],
|
||||
'AllowanceTotalAmount' => $invoice['discount_total'] ?? 0,
|
||||
'PayableAmount' => $invoice['grand_total']
|
||||
];
|
||||
foreach ($fields as $field => $val) {
|
||||
$node = $dom->createElement('cbc:' . $field, number_format((float)$val, 3, '.', ''));
|
||||
$node->setAttribute('currencyID', 'JOD');
|
||||
$monetaryTotal->appendChild($node);
|
||||
}
|
||||
$root->appendChild($monetaryTotal);
|
||||
|
||||
// 7. Invoice Lines
|
||||
foreach ($lines as $line) {
|
||||
$iLine = $dom->createElement('cac:InvoiceLine');
|
||||
$iLine->appendChild($dom->createElement('cbc:ID', (string)$line['line_number']));
|
||||
|
||||
$qty = $dom->createElement('cbc:InvoicedQuantity', number_format((float)$line['quantity'], 3, '.', ''));
|
||||
$qty->setAttribute('unitCode', 'PCE');
|
||||
$iLine->appendChild($qty);
|
||||
|
||||
$lineExt = $dom->createElement('cbc:LineExtensionAmount', number_format((float)$line['line_total'], 3, '.', ''));
|
||||
$lineExt->setAttribute('currencyID', 'JOD');
|
||||
$iLine->appendChild($lineExt);
|
||||
|
||||
$item = $dom->createElement('cac:Item');
|
||||
$item->appendChild($dom->createElement('cbc:Description', $line['description']));
|
||||
$iLine->appendChild($item);
|
||||
|
||||
$price = $dom->createElement('cac:Price');
|
||||
$pAmt = $dom->createElement('cbc:PriceAmount', number_format((float)$line['unit_price'], 3, '.', ''));
|
||||
$pAmt->setAttribute('currencyID', 'JOD');
|
||||
$price->appendChild($pAmt);
|
||||
$iLine->appendChild($price);
|
||||
|
||||
$root->appendChild($iLine);
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?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')
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?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()
|
||||
{
|
||||
// Load from config/secrets.php — NEVER from .env directly
|
||||
$secrets = require dirname(__DIR__, 3) . '/config/secrets.php';
|
||||
$key = $secrets['encryption_key'] ?? '';
|
||||
|
||||
if (strlen($key) !== 32) {
|
||||
throw new \RuntimeException(
|
||||
'ENCRYPTION_KEY_B64 not set or invalid. ' .
|
||||
'Generate: php -r "echo base64_encode(random_bytes(32));"'
|
||||
);
|
||||
}
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function encrypt(string $plaintext): string
|
||||
{
|
||||
$iv = random_bytes(12); // 12 bytes for GCM
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, self::METHOD, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
|
||||
if ($ciphertext === false) throw new \RuntimeException('Encryption failed');
|
||||
return base64_encode($iv) . ':' . base64_encode($ciphertext) . ':' . base64_encode($tag);
|
||||
}
|
||||
|
||||
public function decrypt(string $data): string
|
||||
{
|
||||
[$iv64, $ct64, $tag64] = explode(':', $data);
|
||||
$plaintext = openssl_decrypt(
|
||||
base64_decode($ct64), self::METHOD, $this->key,
|
||||
OPENSSL_RAW_DATA, base64_decode($iv64), base64_decode($tag64)
|
||||
);
|
||||
if ($plaintext === false) throw new \RuntimeException('Decryption failed');
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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 $signature): bool
|
||||
{
|
||||
// 1. Timestamp window (±5 minutes)
|
||||
if (abs(time() - (int)$timestamp) > 300) return false;
|
||||
|
||||
// 2. Nonce replay protection
|
||||
try {
|
||||
$redis = \App\Core\Redis::getInstance();
|
||||
$nonceKey = 'hmac_nonce:' . $nonce;
|
||||
if ($redis->exists($nonceKey)) return false; // Replay attack
|
||||
$redis->setex($nonceKey, 600, '1'); // TTL 10 minutes
|
||||
} catch (\Throwable $e) {
|
||||
// Redis unavailable — log but don't fail (degrade gracefully)
|
||||
error_log('[HMAC] Redis unavailable for nonce check: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 3. Build & compare signature
|
||||
$bodyHash = hash('sha256', $body);
|
||||
$stringToSign = strtoupper($method) . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $bodyHash;
|
||||
$calculated = hash_hmac('sha256', $stringToSign, $secret);
|
||||
|
||||
return hash_equals($calculated, $signature);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?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 — subtotal - discount = Σ(line totals before tax)
|
||||
$lineSumBeforeTax = array_sum(array_map(
|
||||
fn($l) => round(($l['quantity'] * $l['unit_price']) - ($l['discount'] ?? 0), 3),
|
||||
$lines
|
||||
));
|
||||
$expected = round($invoice['subtotal'] - $invoice['discount_total'], 3);
|
||||
if (abs($expected - $lineSumBeforeTax) > 0.01) {
|
||||
$errors[] = [
|
||||
'code' => 'RULE_007',
|
||||
'message_ar' => "خطأ في حساب الخصومات: المتوقع {$expected} JOD، المحسوب {$lineSumBeforeTax} JOD",
|
||||
'message_en' => "Discount integrity error"
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($errors),
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* TotpService
|
||||
*
|
||||
* Implements RFC 6238 for Two-Factor Authentication (TOTP).
|
||||
*/
|
||||
final class TotpService
|
||||
{
|
||||
public function generateSecret(): string
|
||||
{
|
||||
// Generate a random 16-character base32 secret
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$secret = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$secret .= $chars[random_int(0, 31)];
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
public function getQrCodeUrl(string $email, string $secret): string
|
||||
{
|
||||
$issuer = urlencode('Musadaq');
|
||||
$email = urlencode($email);
|
||||
$qrUrl = "otpauth://totp/Musadaq:{$email}?secret={$secret}&issuer=Musadaq";
|
||||
return "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=" . urlencode($qrUrl);
|
||||
}
|
||||
|
||||
public function verify(string $secret, string $code, int $window = 1): bool
|
||||
{
|
||||
$time = floor(time() / 30);
|
||||
for ($i = -$window; $i <= $window; $i++) {
|
||||
$t = $time + $i;
|
||||
$hash = hash_hmac('sha1', pack('N*', 0) . pack('N*', $t), $this->base32Decode($secret));
|
||||
$offset = ord($hash[19]) & 0x0F;
|
||||
$otp = ((ord($hash[$offset]) & 0x7F) << 24 | (ord($hash[$offset+1]) & 0xFF) << 16 | (ord($hash[$offset+2]) & 0xFF) << 8 | (ord($hash[$offset+3]) & 0xFF)) % 1000000;
|
||||
if (str_pad((string)$otp, 6, '0', STR_PAD_LEFT) === $code) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function base32Decode(string $base32): string
|
||||
{
|
||||
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$base32charsFlipped = array_flip(str_split($base32chars));
|
||||
|
||||
$output = '';
|
||||
$v = 0;
|
||||
$vbits = 0;
|
||||
|
||||
for ($i = 0, $j = strlen($base32); $i < $j; $i++) {
|
||||
$v <<= 5;
|
||||
if (isset($base32charsFlipped[$base32[$i]])) {
|
||||
$v += $base32charsFlipped[$base32[$i]];
|
||||
}
|
||||
$vbits += 5;
|
||||
|
||||
while ($vbits >= 8) {
|
||||
$vbits -= 8;
|
||||
$output .= chr(($v >> $vbits) & 0xFF);
|
||||
}
|
||||
}
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user