Compare commits
212 Commits
ff8f525c76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f62455113 | ||
|
|
2f1a6f9c85 | ||
|
|
9ad361e992 | ||
|
|
aceb7d324f | ||
|
|
24a9f064a1 | ||
|
|
663896becb | ||
|
|
e93f1d4f34 | ||
|
|
bddee7ca2d | ||
|
|
2d81aa2fb0 | ||
|
|
e798b970f1 | ||
|
|
a98a5abcce | ||
|
|
3f0534ba0d | ||
|
|
e0dc1712ca | ||
|
|
53284b971a | ||
|
|
fa73062023 | ||
|
|
68f6e76da8 | ||
|
|
8b69c99776 | ||
|
|
cf8cf829d8 | ||
|
|
813197c869 | ||
|
|
ee2ea3a111 | ||
|
|
9ecc03adb1 | ||
|
|
3eecb2f602 | ||
|
|
48fcdaf4b8 | ||
|
|
51119e3201 | ||
|
|
7ee897ff3d | ||
|
|
54a4acdcab | ||
|
|
f5260d854e | ||
|
|
9e078bdfa7 | ||
|
|
7e9a088ea1 | ||
|
|
698d0df01e | ||
|
|
2f1ecca593 | ||
|
|
1ca7e01ce0 | ||
|
|
30da101415 | ||
|
|
e8a9b59a46 | ||
|
|
ae5eba09aa | ||
|
|
d6cedd23c1 | ||
|
|
ba621c9896 | ||
|
|
8948397af9 | ||
|
|
79f98a4afb | ||
|
|
d86a00fe03 | ||
|
|
d6a06cadf9 | ||
|
|
72a00bb308 | ||
|
|
3a9a3be04f | ||
|
|
7541a042a7 | ||
|
|
0dbf812be4 | ||
|
|
e1bdda3cbf | ||
|
|
8780054553 | ||
|
|
d7c7920b4a | ||
|
|
b9ba9c5030 | ||
|
|
c94855ed9c | ||
|
|
f75e2719fa | ||
|
|
32b9d829eb | ||
|
|
47df9253f9 | ||
|
|
c0896468a7 | ||
|
|
9159e2d274 | ||
|
|
c23c58c188 | ||
|
|
812aa7eb5d | ||
|
|
67cc322f5e | ||
|
|
72424bf92c | ||
|
|
07fd3f9ba7 | ||
|
|
7ea42f0f3b | ||
|
|
80949e584c | ||
|
|
0d8ff9a7b1 | ||
|
|
30974da55b | ||
|
|
9295be081c | ||
|
|
b913ff25c8 | ||
|
|
9bfd394b26 | ||
|
|
be0571648a | ||
|
|
155c2d0fc0 | ||
|
|
cfc330e291 | ||
|
|
9832493d59 | ||
|
|
18d678bc39 | ||
|
|
85ea0e4340 | ||
|
|
3db1a12e4b | ||
|
|
753497649a | ||
|
|
df92a44878 | ||
|
|
d2d345b6a0 | ||
|
|
6db8986fca | ||
|
|
4721ca83da | ||
|
|
189382e065 | ||
|
|
b49af44139 | ||
|
|
1cd511f12e | ||
|
|
7528ec992d | ||
|
|
f38a64c6f7 | ||
|
|
7680847e8c | ||
|
|
ed8203a02e | ||
|
|
6b4e7721ee | ||
|
|
23813fee95 | ||
|
|
928e8e27e3 | ||
|
|
1a6ed52a52 | ||
|
|
4994994ad0 | ||
|
|
522885d257 | ||
|
|
08e2a87c10 | ||
|
|
51d1d42f75 | ||
|
|
80f3d257b0 | ||
|
|
e04229dfbe | ||
|
|
d8820efa24 | ||
|
|
528b3ca247 | ||
|
|
3cdab9dccc | ||
|
|
10432e7b81 | ||
|
|
230609e0a0 | ||
|
|
8ee3557109 | ||
|
|
01956fa714 | ||
|
|
3b5f490efc | ||
|
|
24ae4e2183 | ||
|
|
6f3e2b9f50 | ||
|
|
f7aee80553 | ||
|
|
b8d9b3343e | ||
|
|
bd7164ed23 | ||
|
|
209f721cd6 | ||
|
|
a5623d5b84 | ||
|
|
440c8c1633 | ||
|
|
a7915bab46 | ||
|
|
4dc3d3783f | ||
|
|
55806721e7 | ||
|
|
6cefee3d42 | ||
|
|
bfb6368ec8 | ||
|
|
272971fc5b | ||
|
|
57ac6047b8 | ||
|
|
e5b70a01ef | ||
|
|
f206591c01 | ||
|
|
8a935dc362 | ||
|
|
e36a078de2 | ||
|
|
2449e44cb0 | ||
|
|
dd364fc918 | ||
|
|
3d4e636fbe | ||
|
|
019bff7e37 | ||
|
|
a9a2c65bee | ||
|
|
01234bf3f2 | ||
|
|
0dcced4142 | ||
|
|
164651eb6d | ||
|
|
abccf033a6 | ||
|
|
79274ce72f | ||
|
|
c8fef468aa | ||
|
|
b874b66e6b | ||
|
|
3a29f26d56 | ||
|
|
13c2f75432 | ||
|
|
d9d2edac47 | ||
|
|
9952e0eca5 | ||
|
|
dc2ba2ebcb | ||
|
|
2176893eee | ||
|
|
c7a152af81 | ||
|
|
05eba6adfb | ||
|
|
97ff911751 | ||
|
|
c63d9944ee | ||
|
|
fde1ee03d9 | ||
|
|
50538bc5b9 | ||
|
|
cfbd9c0009 | ||
|
|
dcbf8bd04f | ||
|
|
2f6a08800d | ||
|
|
fdaffaafbc | ||
|
|
b2ed480d93 | ||
|
|
ed525250c2 | ||
|
|
79c88c47cc | ||
|
|
ac12106770 | ||
|
|
5f7018390a | ||
|
|
fbfaae8af4 | ||
|
|
2585abe2fa | ||
|
|
97c4e8620c | ||
|
|
2a474b946c | ||
|
|
6b940fc4b1 | ||
|
|
3d21444d1f | ||
|
|
70446519e0 | ||
|
|
23189713dc | ||
|
|
75f969f821 | ||
|
|
e5f8d8151d | ||
|
|
ff1a5f8b8c | ||
|
|
3249a227d6 | ||
|
|
8d499716ce | ||
|
|
3ea64d59ce | ||
|
|
691305340a | ||
|
|
2d25bee2a6 | ||
|
|
51ae81a9fa | ||
|
|
98c4b922be | ||
|
|
47652b4d95 | ||
|
|
863dabc069 | ||
|
|
ebb70e657e | ||
|
|
02309488ad | ||
|
|
e704ba127c | ||
|
|
3e9d380e6d | ||
|
|
c6040b3b85 | ||
|
|
3ff2d8d8e1 | ||
|
|
303205d52d | ||
|
|
b21951e4c8 | ||
|
|
ea1d78cb85 | ||
|
|
ee37a4fa52 | ||
|
|
2af604df7f | ||
|
|
5dd8fe46f3 | ||
|
|
3976a5346b | ||
|
|
87d6b8b1c0 | ||
|
|
282f33ca3a | ||
|
|
08106ac4ea | ||
|
|
90f2f6f6e3 | ||
|
|
ad48142492 | ||
|
|
79308d7f9b | ||
|
|
5abc22dcd8 | ||
|
|
e9cea98e95 | ||
|
|
b4ac1e8775 | ||
|
|
cd85fcf2bd | ||
|
|
671db50f16 | ||
|
|
8357add763 | ||
|
|
2ac63eef47 | ||
|
|
c1d31231b4 | ||
|
|
b6db8da450 | ||
|
|
bef134ea77 | ||
|
|
87809ac893 | ||
|
|
6d2c61497c | ||
|
|
13bbc29e0e | ||
|
|
2732229642 | ||
|
|
ab9625839e | ||
|
|
089a2b76c0 | ||
|
|
e1d4917369 |
2225
PROJECT_DOCUMENTATION.md
Normal file
2225
PROJECT_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
181
app/Core/AI.php
Normal file
181
app/Core/AI.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use App\Services\InvoiceExtractionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini AI Integration for Invoice Extraction
|
||||||
|
* Optimized for Jordan UBL 2.1 Compliance
|
||||||
|
*/
|
||||||
|
class AI
|
||||||
|
{
|
||||||
|
private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/" . AIConfig::MODEL_NAME . ":generateContent";
|
||||||
|
|
||||||
|
private static int $maxRetries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Data from Invoice Image or PDF (Base64)
|
||||||
|
*/
|
||||||
|
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
|
||||||
|
{
|
||||||
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
|
if (!$apiKey) {
|
||||||
|
error_log('AI Error: GEMINI_API_KEY is missing');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = AIConfig::getExtractionPrompt();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
"contents" => [
|
||||||
|
[
|
||||||
|
"parts" => [
|
||||||
|
["text" => $prompt . " If the image is not an invoice, is blank, or is completely unreadable, return ONLY: {\"error\": \"invalid_invoice\"}. DO NOT guess or invent data."],
|
||||||
|
[
|
||||||
|
"inline_data" => [
|
||||||
|
"mime_type" => $mimeType,
|
||||||
|
"data" => $base64Data
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"generationConfig" => [
|
||||||
|
"response_mime_type" => "application/json"
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Retry with exponential backoff for 503/429 errors
|
||||||
|
for ($attempt = 1; $attempt <= self::$maxRetries; $attempt++) {
|
||||||
|
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
|
||||||
|
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']);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
|
||||||
|
if ($attempt < self::$maxRetries) {
|
||||||
|
$wait = pow(2, $attempt) + rand(1, 3);
|
||||||
|
echo " Retrying in {$wait}s (cURL error)...\n";
|
||||||
|
sleep($wait);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
break; // Success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on 503 (overloaded) or 429 (rate limit)
|
||||||
|
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
|
||||||
|
$wait = pow(2, $attempt) + rand(1, 3);
|
||||||
|
echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n";
|
||||||
|
sleep($wait);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("AI Error: All retries exhausted. Last code: $httpCode");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = json_decode($response, true);
|
||||||
|
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
|
||||||
|
if (!$textResponse) return null;
|
||||||
|
|
||||||
|
// --- ADDED FOR DEBUGGING ---
|
||||||
|
@file_put_contents(STORAGE_PATH . '/logs/worker.log', "[" . date('Y-m-d H:i:s') . "] [AI_RAW_RESPONSE]\n" . $textResponse . "\n", FILE_APPEND);
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
$data = json_decode($textResponse, true);
|
||||||
|
if (isset($data['error']) && $data['error'] === 'invalid_invoice') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the AI returns an array of invoices, extract the first one
|
||||||
|
if (isset($data['invoices']) && is_array($data['invoices']) && count($data['invoices']) > 0) {
|
||||||
|
$data = $data['invoices'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track token usage from Gemini response
|
||||||
|
$usage = $result['usageMetadata'] ?? [];
|
||||||
|
if (!empty($usage)) {
|
||||||
|
self::logTokenUsage($usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log AI token usage to the database for cost tracking
|
||||||
|
*/
|
||||||
|
private static function logTokenUsage(array $usage): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$inputTokens = (int)($usage['promptTokenCount'] ?? 0);
|
||||||
|
$outputTokens = (int)($usage['candidatesTokenCount'] ?? 0);
|
||||||
|
$totalTokens = (int)($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens));
|
||||||
|
|
||||||
|
// Gemini Flash Lite pricing: $0.075/1M input, $0.30/1M output
|
||||||
|
$inputCost = ($inputTokens / 1000000) * 0.075;
|
||||||
|
$outputCost = ($outputTokens / 1000000) * 0.30;
|
||||||
|
$totalCostUsd = $inputCost + $outputCost;
|
||||||
|
$totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD
|
||||||
|
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW())
|
||||||
|
")->execute([
|
||||||
|
$inputTokens,
|
||||||
|
$outputTokens,
|
||||||
|
$totalTokens,
|
||||||
|
round($totalCostUsd, 8),
|
||||||
|
round($totalCostJod, 8),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Never crash the main flow for logging
|
||||||
|
error_log("[AI] Token usage log failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated AI usage stats
|
||||||
|
*/
|
||||||
|
public static function getUsageStats(?string $tenantId = null): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(cost_usd), 0) as total_cost_usd,
|
||||||
|
COALESCE(SUM(cost_jod), 0) as total_cost_jod,
|
||||||
|
COALESCE(AVG(total_tokens), 0) as avg_tokens_per_request,
|
||||||
|
COALESCE(AVG(cost_jod), 0) as avg_cost_jod_per_request
|
||||||
|
FROM ai_usage_log
|
||||||
|
");
|
||||||
|
return $stmt->fetch() ?: [];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Core/AIConfig.php
Normal file
22
app/Core/AIConfig.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use App\Services\InvoiceExtractionService;
|
||||||
|
|
||||||
|
class AIConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The model name preferred by the user
|
||||||
|
*/
|
||||||
|
public const MODEL_NAME = "gemini-flash-lite-latest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized prompt for invoice extraction
|
||||||
|
*/
|
||||||
|
public static function getExtractionPrompt(): string
|
||||||
|
{
|
||||||
|
$service = new InvoiceExtractionService();
|
||||||
|
return $service->buildExtractionPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/Core/AiUsageLogger.php
Normal file
102
app/Core/AiUsageLogger.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Usage Logger Service
|
||||||
|
* Records every AI API call with token counts and estimated cost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class AiUsageLogger
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cost per 1M tokens (input/output) for each model.
|
||||||
|
* Update these when pricing changes.
|
||||||
|
*/
|
||||||
|
private const MODEL_PRICING = [
|
||||||
|
'gemini-1.5-flash' => [
|
||||||
|
'input' => 0.075, // $0.075 per 1M input tokens
|
||||||
|
'output' => 0.30, // $0.30 per 1M output tokens
|
||||||
|
],
|
||||||
|
'gemini-2.0-flash' => [
|
||||||
|
'input' => 0.10,
|
||||||
|
'output' => 0.40,
|
||||||
|
],
|
||||||
|
'gemini-1.5-pro' => [
|
||||||
|
'input' => 1.25,
|
||||||
|
'output' => 5.00,
|
||||||
|
],
|
||||||
|
'grok-2' => [
|
||||||
|
'input' => 2.00,
|
||||||
|
'output' => 10.00,
|
||||||
|
],
|
||||||
|
'whisper-large-v3' => [
|
||||||
|
'input' => 0.111, // $0.111 per 1M input tokens (Groq)
|
||||||
|
'output' => 0.0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an AI usage event.
|
||||||
|
*
|
||||||
|
* @param string $tenantId
|
||||||
|
* @param string $actionType One of: invoice_extraction, voice_transcribe, voice_intent, report_generation, chatbot
|
||||||
|
* @param string $modelName e.g. gemini-1.5-flash
|
||||||
|
* @param int $promptTokens
|
||||||
|
* @param int $completionTokens
|
||||||
|
* @param string|null $userId
|
||||||
|
* @param string|null $companyId
|
||||||
|
* @param array|null $metadata Any extra info (invoice_id, etc.)
|
||||||
|
*/
|
||||||
|
public static function log(
|
||||||
|
string $tenantId,
|
||||||
|
string $actionType,
|
||||||
|
string $modelName,
|
||||||
|
int $promptTokens,
|
||||||
|
int $completionTokens,
|
||||||
|
?string $userId = null,
|
||||||
|
?string $companyId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): void {
|
||||||
|
$totalTokens = $promptTokens + $completionTokens;
|
||||||
|
$estimatedCost = self::estimateCost($modelName, $promptTokens, $completionTokens);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO ai_usage_log
|
||||||
|
(tenant_id, user_id, company_id, action_type, model_name,
|
||||||
|
prompt_tokens, completion_tokens, total_tokens, estimated_cost,
|
||||||
|
request_metadata, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$companyId,
|
||||||
|
$actionType,
|
||||||
|
$modelName,
|
||||||
|
$promptTokens,
|
||||||
|
$completionTokens,
|
||||||
|
$totalTokens,
|
||||||
|
$estimatedCost,
|
||||||
|
$metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Logging should never break the main flow
|
||||||
|
error_log('[AiUsageLogger] Failed to log: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate cost in USD based on model pricing.
|
||||||
|
*/
|
||||||
|
private static function estimateCost(string $model, int $inputTokens, int $outputTokens): float
|
||||||
|
{
|
||||||
|
$pricing = self::MODEL_PRICING[$model] ?? ['input' => 0.10, 'output' => 0.40];
|
||||||
|
|
||||||
|
$inputCost = ($inputTokens / 1_000_000) * $pricing['input'];
|
||||||
|
$outputCost = ($outputTokens / 1_000_000) * $pricing['output'];
|
||||||
|
|
||||||
|
return round($inputCost + $outputCost, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Core/AuditLogger.php
Normal file
63
app/Core/AuditLogger.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Audit Logger — Records all important actions for compliance and debugging.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* AuditLogger::log('invoice.approved', 'invoice', $invoiceId, $oldData, $newData, $decoded);
|
||||||
|
* AuditLogger::log('user.login', 'user', $userId, decoded: $decoded);
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class AuditLogger
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log an audit event.
|
||||||
|
*
|
||||||
|
* @param string $action e.g. 'invoice.approved', 'user.created', 'company.deleted'
|
||||||
|
* @param string|null $entityType e.g. 'invoice', 'user', 'company'
|
||||||
|
* @param string|null $entityId UUID of the affected entity
|
||||||
|
* @param array|null $oldData Previous state (for updates/deletes)
|
||||||
|
* @param array|null $newData New state (for creates/updates)
|
||||||
|
* @param array|null $decoded JWT decoded payload (to extract user_id, tenant_id)
|
||||||
|
*/
|
||||||
|
public static function log(
|
||||||
|
string $action,
|
||||||
|
?string $entityType = null,
|
||||||
|
?string $entityId = null,
|
||||||
|
?array $oldData = null,
|
||||||
|
?array $newData = null,
|
||||||
|
?array $decoded = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
$userId = $decoded['user_id'] ?? null;
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||||
|
$userAgent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 500);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO audit_logs (id, tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$action,
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$oldData ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$newData ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Audit logging should NEVER crash the main request
|
||||||
|
error_log("[AuditLogger] Failed to log action '{$action}': " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Core/Cache.php
Normal file
64
app/Core/Cache.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Redis Cache Wrapper
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Cache
|
||||||
|
{
|
||||||
|
private static ?\Predis\Client $client = null;
|
||||||
|
|
||||||
|
public static function getInstance(): ?\Predis\Client
|
||||||
|
{
|
||||||
|
if (self::$client === null) {
|
||||||
|
$host = env('REDIS_HOST', '127.0.0.1');
|
||||||
|
$port = (int)env('REDIS_PORT', 6379);
|
||||||
|
$pass = env('REDIS_PASSWORD', null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!class_exists('\Predis\Client')) {
|
||||||
|
throw new \Exception('Predis client is not installed. Please run composer install.');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$client = new \Predis\Client([
|
||||||
|
'scheme' => 'tcp',
|
||||||
|
'host' => $host,
|
||||||
|
'port' => $port,
|
||||||
|
'password' => $pass,
|
||||||
|
]);
|
||||||
|
self::$client->connect();
|
||||||
|
} catch (\Throwable $e) { // Catch \Throwable instead of \Exception to catch fatal class errors
|
||||||
|
error_log("Redis Connection Error: " . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, $value, int $ttl = 3600): bool
|
||||||
|
{
|
||||||
|
$redis = self::getInstance();
|
||||||
|
if (!$redis) return false;
|
||||||
|
|
||||||
|
$redis->setex($key, $ttl, serialize($value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key)
|
||||||
|
{
|
||||||
|
$redis = self::getInstance();
|
||||||
|
if (!$redis) return false;
|
||||||
|
|
||||||
|
$data = $redis->get($key);
|
||||||
|
return $data ? unserialize($data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(string $key): void
|
||||||
|
{
|
||||||
|
$redis = self::getInstance();
|
||||||
|
if ($redis) $redis->del([$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,4 +43,9 @@ final class Database
|
|||||||
|
|
||||||
return self::$instance;
|
return self::$instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function generateUuid(): string
|
||||||
|
{
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -44,20 +44,38 @@ final class Encryption
|
|||||||
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
|
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle common prefixing issues or trailing whitespace
|
||||||
|
$encryptedData = trim($encryptedData);
|
||||||
|
if (str_starts_with($encryptedData, '==')) {
|
||||||
|
$encryptedData = substr($encryptedData, 2);
|
||||||
|
}
|
||||||
|
|
||||||
$encryptionKey = hash('sha256', $key, true);
|
$encryptionKey = hash('sha256', $key, true);
|
||||||
$decoded = base64_decode($encryptedData);
|
$decoded = base64_decode($encryptedData, true);
|
||||||
|
|
||||||
if ($decoded === false) return false;
|
if ($decoded === false) {
|
||||||
|
error_log("ENCRYPTION ERROR: Invalid base64 data provided for decryption.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$ivLength = openssl_cipher_iv_length(self::CIPHER);
|
$ivLength = openssl_cipher_iv_length(self::CIPHER);
|
||||||
$tagLength = 16;
|
$tagLength = 16;
|
||||||
|
|
||||||
if (strlen($decoded) < $ivLength + $tagLength) return false;
|
if (strlen($decoded) < $ivLength + $tagLength) {
|
||||||
|
// This is likely legacy unencrypted data, return false silently
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$iv = substr($decoded, 0, $ivLength);
|
$iv = substr($decoded, 0, $ivLength);
|
||||||
$tag = substr($decoded, $ivLength, $tagLength);
|
$tag = substr($decoded, $ivLength, $tagLength);
|
||||||
$ciphertext = substr($decoded, $ivLength + $tagLength);
|
$ciphertext = substr($decoded, $ivLength + $tagLength);
|
||||||
|
|
||||||
return openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
|
$result = openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
error_log("ENCRYPTION ERROR: openssl_decrypt failed. Key might be wrong or data corrupted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
213
app/Core/JoFotara.php
Normal file
213
app/Core/JoFotara.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JoFotara (Jordan E-Invoicing) Integration Core
|
||||||
|
* Handles UBL 2.1 XML Generation, Cryptography, and API Communication
|
||||||
|
*/
|
||||||
|
class JoFotara
|
||||||
|
{
|
||||||
|
private string $baseUrl = 'https://backend.jofotara.gov.jo/core/invoices/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Generate UBL 2.1 XML for an invoice
|
||||||
|
*/
|
||||||
|
public function generateXML(array $invoice, array $company): string
|
||||||
|
{
|
||||||
|
$issueDate = $invoice['invoice_date'] ?? date('Y-m-d');
|
||||||
|
$issueTime = date('H:i:s');
|
||||||
|
$typeCode = $invoice['ubl_type_code'] ?? '388';
|
||||||
|
$category = $invoice['invoice_category'] ?? 'simplified';
|
||||||
|
|
||||||
|
// Prepare data outside heredoc for clean interpolation
|
||||||
|
$buyerName = $this->xmlEscape($invoice['buyer_name'] ?: 'عميل نقدي');
|
||||||
|
$buyerId = $invoice['buyer_tin'] ?: $invoice['buyer_national_id'] ?: '000000000';
|
||||||
|
$payMethod = $invoice['payment_method_code'] ?: '013';
|
||||||
|
$supplierName = $this->xmlEscape($company['name']);
|
||||||
|
$supplierAddress = $this->xmlEscape($company['address'] ?? '');
|
||||||
|
|
||||||
|
$xml = <<<XML
|
||||||
|
<?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">
|
||||||
|
|
||||||
|
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
|
||||||
|
<cbc:CustomizationID>urn:www.cen.eu:en16931:2017#compliant#urn:www.josefotara.jo:trns:ubl:3.0</cbc:CustomizationID>
|
||||||
|
<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
|
||||||
|
<cbc:ID>{$invoice['invoice_number']}</cbc:ID>
|
||||||
|
<cbc:IssueDate>{$issueDate}</cbc:IssueDate>
|
||||||
|
<cbc:IssueTime>{$issueTime}</cbc:IssueTime>
|
||||||
|
<cbc:InvoiceTypeCode name="{$category}">{$typeCode}</cbc:InvoiceTypeCode>
|
||||||
|
<cbc:DocumentCurrencyCode>JOD</cbc:DocumentCurrencyCode>
|
||||||
|
<cbc:TaxCurrencyCode>JOD</cbc:TaxCurrencyCode>
|
||||||
|
|
||||||
|
<cac:AccountingSupplierParty>
|
||||||
|
<cac:Party>
|
||||||
|
<cac:PartyName><cbc:Name>{$supplierName}</cbc:Name></cac:PartyName>
|
||||||
|
<cac:PostalAddress>
|
||||||
|
<cbc:StreetName>{$supplierAddress}</cbc:StreetName>
|
||||||
|
<cac:Country><cbc:IdentificationCode>JO</cbc:IdentificationCode></cac:Country>
|
||||||
|
</cac:PostalAddress>
|
||||||
|
<cac:PartyTaxScheme>
|
||||||
|
<cbc:CompanyID>{$company['tax_identification_number']}</cbc:CompanyID>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:PartyTaxScheme>
|
||||||
|
<cac:PartyLegalEntity>
|
||||||
|
<cbc:RegistrationName>{$supplierName}</cbc:RegistrationName>
|
||||||
|
</cac:PartyLegalEntity>
|
||||||
|
</cac:Party>
|
||||||
|
</cac:AccountingSupplierParty>
|
||||||
|
|
||||||
|
<cac:AccountingCustomerParty>
|
||||||
|
<cac:Party>
|
||||||
|
<cac:PartyName><cbc:Name>{$buyerName}</cbc:Name></cac:PartyName>
|
||||||
|
<cac:PartyTaxScheme>
|
||||||
|
<cbc:CompanyID>{$buyerId}</cbc:CompanyID>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:PartyTaxScheme>
|
||||||
|
</cac:Party>
|
||||||
|
</cac:AccountingCustomerParty>
|
||||||
|
|
||||||
|
<cac:PaymentMeans>
|
||||||
|
<cbc:PaymentMeansCode>{$payMethod}</cbc:PaymentMeansCode>
|
||||||
|
</cac:PaymentMeans>
|
||||||
|
|
||||||
|
<cac:TaxTotal>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
|
||||||
|
<cac:TaxSubtotal>
|
||||||
|
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxableAmount>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
|
||||||
|
<cac:TaxCategory>
|
||||||
|
<cbc:ID>S</cbc:ID>
|
||||||
|
<cbc:Percent>16.000</cbc:Percent>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:TaxCategory>
|
||||||
|
</cac:TaxSubtotal>
|
||||||
|
</cac:TaxTotal>
|
||||||
|
|
||||||
|
<cac:LegalMonetaryTotal>
|
||||||
|
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($invoice['subtotal'])}</cbc:LineExtensionAmount>
|
||||||
|
<cbc:TaxExclusiveAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxExclusiveAmount>
|
||||||
|
<cbc:TaxInclusiveAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:TaxInclusiveAmount>
|
||||||
|
<cbc:AllowanceTotalAmount currencyID="JOD">{$this->fmt($invoice['discount_total'])}</cbc:AllowanceTotalAmount>
|
||||||
|
<cbc:PayableAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:PayableAmount>
|
||||||
|
</cac:LegalMonetaryTotal>
|
||||||
|
|
||||||
|
{$this->buildInvoiceLines($invoice['items'])}
|
||||||
|
</Invoice>
|
||||||
|
XML;
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInvoiceLines(array $items): string
|
||||||
|
{
|
||||||
|
$result = '';
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$taxAmount = round($item['line_total'] * $item['tax_rate'], 3);
|
||||||
|
$taxCategory = $item['tax_rate'] > 0 ? 'S' : 'Z';
|
||||||
|
|
||||||
|
$result .= <<<XML
|
||||||
|
<cac:InvoiceLine>
|
||||||
|
<cbc:ID>{$item['line_number']}</cbc:ID>
|
||||||
|
<cbc:InvoicedQuantity unitCode="PCE">{$this->fmt($item['quantity'])}</cbc:InvoicedQuantity>
|
||||||
|
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:LineExtensionAmount>
|
||||||
|
<cac:TaxTotal>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
|
||||||
|
<cac:TaxSubtotal>
|
||||||
|
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:TaxableAmount>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
|
||||||
|
<cac:TaxCategory>
|
||||||
|
<cbc:ID>{$taxCategory}</cbc:ID>
|
||||||
|
<cbc:Percent>{$this->fmt($item['tax_rate'] * 100)}</cbc:Percent>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:TaxCategory>
|
||||||
|
</cac:TaxSubtotal>
|
||||||
|
</cac:TaxTotal>
|
||||||
|
<cac:Item>
|
||||||
|
<cbc:Description>{$this->xmlEscape($item['description'])}</cbc:Description>
|
||||||
|
</cac:Item>
|
||||||
|
<cac:Price>
|
||||||
|
<cbc:PriceAmount currencyID="JOD">{$this->fmt($item['unit_price'])}</cbc:PriceAmount>
|
||||||
|
</cac:Price>
|
||||||
|
</cac:InvoiceLine>
|
||||||
|
XML;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fmt(float $val): string { return number_format($val, 3, '.', ''); }
|
||||||
|
private function xmlEscape(string $str): string { return htmlspecialchars($str, ENT_XML1, 'UTF-8'); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Generate Base64 TLV QR Code (Local Fallback)
|
||||||
|
*/
|
||||||
|
public function generateQRCode(array $invoiceData): string
|
||||||
|
{
|
||||||
|
$sellerName = $invoiceData['supplier_name'] ?? '';
|
||||||
|
$taxNumber = $invoiceData['supplier_tin'] ?? '';
|
||||||
|
$timestamp = date('Y-m-d\TH:i:s\Z', strtotime($invoiceData['invoice_date'] ?? 'now'));
|
||||||
|
$total = number_format($invoiceData['grand_total'] ?? 0, 3, '.', '');
|
||||||
|
$vat = number_format($invoiceData['tax_amount'] ?? 0, 3, '.', '');
|
||||||
|
|
||||||
|
$tlv = $this->toTLV(1, $sellerName) .
|
||||||
|
$this->toTLV(2, $taxNumber) .
|
||||||
|
$this->toTLV(3, $timestamp) .
|
||||||
|
$this->toTLV(4, $total) .
|
||||||
|
$this->toTLV(5, $vat);
|
||||||
|
|
||||||
|
return base64_encode($tlv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toTLV(int $tag, string $value): string
|
||||||
|
{
|
||||||
|
return chr($tag) . chr(strlen($value)) . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. Submit Invoice to JoFotara API
|
||||||
|
*/
|
||||||
|
public function submitInvoice(string $xmlContent, string $clientId, string $secretKey): array
|
||||||
|
{
|
||||||
|
// For production, we must encode XML in Base64 and wrap in JSON
|
||||||
|
$payload = json_encode([
|
||||||
|
'invoice' => base64_encode($xmlContent)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($this->baseUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
"ClientId: $clientId",
|
||||||
|
"SecretKey: $secretKey"
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$decoded = json_decode($response, true) ?? [];
|
||||||
|
$decoded['_http_code'] = $httpCode;
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'uuid' => $decoded['invoiceUUID'] ?? $decoded['uuid'] ?? 'mock-' . uniqid(),
|
||||||
|
'qrCode' => $decoded['qrCode'] ?? $decoded['QRCode'] ?? null,
|
||||||
|
'raw' => $decoded
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $decoded['errorMessage'] ?? 'API Connection Failed',
|
||||||
|
'raw' => $decoded
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Core/PaymentParser.php
Normal file
66
app/Core/PaymentParser.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class PaymentParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extract reference number from raw SMS text
|
||||||
|
*/
|
||||||
|
public static function extractReference(string $text): ?string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if (empty($text)) return null;
|
||||||
|
|
||||||
|
// If it's already a single word (likely just the ref number), return it
|
||||||
|
if (!str_contains($text, ' ') && strlen($text) > 5) {
|
||||||
|
return strtoupper($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Orange Money / Jordanian Arabic format: بالرقم المرجعي JIBA... or OJM...
|
||||||
|
if (preg_match('/بالرقم المرجعي\s+([A-Z0-9\-]+)/i', $text, $matches)) {
|
||||||
|
return strtoupper($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. English "Ref" format: Ref CS260210...
|
||||||
|
if (preg_match('/Ref\s+([A-Z0-9]+)/i', $text, $matches)) {
|
||||||
|
return strtoupper($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generic "Reference" or "رقم الحوالة"
|
||||||
|
if (preg_match('/(?:Reference|المرجع|رقم الحوالة|رقم العملية)[:\s]+([A-Z0-9\-]+)/iu', $text, $matches)) {
|
||||||
|
return strtoupper($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try to find any long alphanumeric string that looks like a ref (8+ chars)
|
||||||
|
// This is a fallback and might be risky, but useful for copy-pasting just the ref.
|
||||||
|
if (preg_match('/([A-Z]{1,4}[0-9]{5,})/i', $text, $matches)) {
|
||||||
|
return strtoupper($matches[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract amount from raw SMS text
|
||||||
|
*/
|
||||||
|
public static function extractAmount(string $text): float
|
||||||
|
{
|
||||||
|
// بمبلغ 61.25 دينار
|
||||||
|
if (preg_match('/بمبلغ\s+([\d\.]+)/u', $text, $matches)) {
|
||||||
|
return (float)$matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// JOD 28.550
|
||||||
|
if (preg_match('/JOD\s+([\d\.]+)/i', $text, $matches)) {
|
||||||
|
return (float)$matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 دينار اردني
|
||||||
|
if (preg_match('/([\d\.]+)\s+دينار/u', $text, $matches)) {
|
||||||
|
return (float)$matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Core/Validator.php
Normal file
49
app/Core/Validator.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple Data Validator
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Validator
|
||||||
|
{
|
||||||
|
public static function validate(array $data, array $rules): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
foreach ($rules as $field => $rule) {
|
||||||
|
$value = $data[$field] ?? null;
|
||||||
|
|
||||||
|
if (str_contains($rule, 'required') && (empty($value) && $value !== '0')) {
|
||||||
|
$errors[$field] = "The {$field} field is required.";
|
||||||
|
continue; // Skip further rules if required field is missing
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($rule, 'email') && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[$field] = "The {$field} must be a valid email address.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password strength: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit
|
||||||
|
if (str_contains($rule, 'strong_password') && !empty($value)) {
|
||||||
|
if (strlen($value) < 8) {
|
||||||
|
$errors[$field] = 'كلمة المرور يجب أن تكون 8 أحرف على الأقل.';
|
||||||
|
} elseif (!preg_match('/[A-Z]/', $value)) {
|
||||||
|
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف كبير واحد على الأقل.';
|
||||||
|
} elseif (!preg_match('/[a-z]/', $value)) {
|
||||||
|
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف صغير واحد على الأقل.';
|
||||||
|
} elseif (!preg_match('/[0-9]/', $value)) {
|
||||||
|
$errors[$field] = 'كلمة المرور يجب أن تحتوي على رقم واحد على الأقل.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic min length: min:8
|
||||||
|
if (preg_match('/min:(\d+)/', $rule, $m) && !empty($value)) {
|
||||||
|
if (mb_strlen($value) < (int)$m[1]) {
|
||||||
|
$errors[$field] = "The {$field} must be at least {$m[1]} characters.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Middleware/AuthMiddleware.php
Normal file
49
app/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple Authentication Middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\JWT;
|
||||||
|
|
||||||
|
final class AuthMiddleware
|
||||||
|
{
|
||||||
|
public static function check(): array
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
|
||||||
|
if (!str_starts_with($authHeader, 'Bearer ')) {
|
||||||
|
json_error('Unauthorized: Missing or invalid token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = substr($authHeader, 7);
|
||||||
|
$secret = env('JWT_SECRET');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = JWT::decode($token, $secret);
|
||||||
|
|
||||||
|
if (!$decoded) {
|
||||||
|
// Check if it's specifically expired if your JWT class supports it,
|
||||||
|
// otherwise just send the standard 401 with a code.
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'انتهت صلاحية الجلسة',
|
||||||
|
'code' => 'TOKEN_EXPIRED',
|
||||||
|
'redirect'=> '/login.php'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Middleware/CompanyAccessMiddleware.php
Normal file
104
app/Middleware/CompanyAccessMiddleware.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Company Access Middleware
|
||||||
|
*
|
||||||
|
* Ensures that the current user has access to the requested company.
|
||||||
|
* - super_admin: access to ALL companies across ALL tenants
|
||||||
|
* - admin: access to ALL companies within their tenant
|
||||||
|
* - accountant: access ONLY to their assigned company (users.company_id)
|
||||||
|
* - viewer: access ONLY to their assigned company (read-only)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $decoded = AuthMiddleware::check();
|
||||||
|
* CompanyAccessMiddleware::check($companyId, $decoded);
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
final class CompanyAccessMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the user can access the given company.
|
||||||
|
* Halts with 403 if access is denied.
|
||||||
|
*/
|
||||||
|
public static function check(string $companyId, array $decoded): void
|
||||||
|
{
|
||||||
|
$role = $decoded['role'] ?? '';
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? '';
|
||||||
|
$userId = $decoded['user_id'] ?? '';
|
||||||
|
|
||||||
|
// super_admin can access everything
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 1. Verify the company belongs to the user's tenant
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الشركة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($company['tenant_id'] !== $tenantId) {
|
||||||
|
// Company exists but belongs to a different tenant — treat as 404 (don't leak info)
|
||||||
|
json_error('الشركة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. admin can access all companies in their tenant
|
||||||
|
if ($role === 'admin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. accountant / viewer — must be assigned to this specific company
|
||||||
|
$stmt = $db->prepare("SELECT company_id FROM users WHERE id = ? AND tenant_id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$userId, $tenantId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || $user['company_id'] !== $companyId) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ليس لديك صلاحية للوصول إلى هذه الشركة',
|
||||||
|
'code' => 'COMPANY_ACCESS_DENIED',
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of company IDs that the user can access.
|
||||||
|
* Useful for listing/filtering queries.
|
||||||
|
*/
|
||||||
|
public static function getAccessibleCompanyIds(array $decoded): ?array
|
||||||
|
{
|
||||||
|
$role = $decoded['role'] ?? '';
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? '';
|
||||||
|
$userId = $decoded['user_id'] ?? '';
|
||||||
|
|
||||||
|
// super_admin & admin: null means "no filter" (access all)
|
||||||
|
if ($role === 'super_admin' || $role === 'admin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountant / viewer: only their assigned company
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT company_id FROM users WHERE id = ? AND tenant_id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$userId, $tenantId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user && $user['company_id']) {
|
||||||
|
return [$user['company_id']];
|
||||||
|
}
|
||||||
|
|
||||||
|
return []; // No access to any company
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Middleware/HmacMiddleware.php
Normal file
62
app/Middleware/HmacMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HMAC Request Signature Middleware
|
||||||
|
*
|
||||||
|
* Verifies that incoming requests are signed with a shared secret,
|
||||||
|
* preventing replay attacks and ensuring request integrity.
|
||||||
|
*
|
||||||
|
* Client must send:
|
||||||
|
* X-Timestamp: Unix timestamp (seconds)
|
||||||
|
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
final class HmacMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
|
||||||
|
*/
|
||||||
|
public static function verify(int $maxAgeSeconds = 300): void
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||||
|
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||||
|
|
||||||
|
// 1. Ensure both headers are present
|
||||||
|
if (empty($signature) || empty($timestamp)) {
|
||||||
|
json_error('Missing HMAC signature or timestamp', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate timestamp is numeric
|
||||||
|
if (!ctype_digit((string)$timestamp)) {
|
||||||
|
json_error('Invalid timestamp format', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Replay attack prevention — reject stale requests
|
||||||
|
$age = abs(time() - (int)$timestamp);
|
||||||
|
if ($age > $maxAgeSeconds) {
|
||||||
|
json_error('Request expired. Check your system clock.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the expected signature
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$payload = $timestamp . '.' . $body;
|
||||||
|
$secret = env('HMAC_SECRET_KEY');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify using constant-time comparison (prevents timing attacks)
|
||||||
|
if (!Security::verifySignature($payload, $signature, $secret)) {
|
||||||
|
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||||
|
json_error('Invalid request signature', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
295
app/Middleware/QuotaMiddleware.php
Normal file
295
app/Middleware/QuotaMiddleware.php
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Quota Enforcement Middleware
|
||||||
|
*
|
||||||
|
* Checks tenant subscription limits before allowing resource creation.
|
||||||
|
* Automatically resets monthly counters when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
|
final class QuotaMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the tenant can upload more invoices this month.
|
||||||
|
* Automatically resets the counter if the billing period has ended.
|
||||||
|
*
|
||||||
|
* @return array The current subscription data (for UI display)
|
||||||
|
*/
|
||||||
|
public static function checkInvoiceQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
|
if ($sub === false || $sub === null) {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Fetch subscription with plan info
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($sub) {
|
||||||
|
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
if ($sub['status'] === 'cancelled') {
|
||||||
|
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sub['status'] === 'past_due') {
|
||||||
|
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reset period counter if billing period has ended
|
||||||
|
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
||||||
|
$newStart = date('Y-m-d H:i:s');
|
||||||
|
$cycle = $sub['billing_cycle'] ?? 'annual';
|
||||||
|
$interval = ($cycle === 'monthly') ? '+1 month' : '+1 year';
|
||||||
|
$newEnd = date('Y-m-d H:i:s', strtotime($interval));
|
||||||
|
|
||||||
|
$resetStmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = 0,
|
||||||
|
current_period_start = ?,
|
||||||
|
current_period_end = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
|
||||||
|
|
||||||
|
$sub['invoices_used_this_month'] = 0;
|
||||||
|
$sub['current_period_start'] = $newStart;
|
||||||
|
$sub['current_period_end'] = $newEnd;
|
||||||
|
|
||||||
|
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invoice quota
|
||||||
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
|
||||||
|
if ($used >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
||||||
|
'quota_type' => 'invoices',
|
||||||
|
'used' => $used,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the monthly invoice counter after a successful upload.
|
||||||
|
*/
|
||||||
|
public static function incrementInvoiceUsage(string $tenantId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = invoices_used_this_month + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
Cache::delete("quota_sub_{$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more companies.
|
||||||
|
*/
|
||||||
|
public static function checkCompanyQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active companies
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM companies
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL)
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$limit = (int)$sub['max_companies'];
|
||||||
|
|
||||||
|
if ($currentCount >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'companies',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more users.
|
||||||
|
*/
|
||||||
|
public static function checkUserQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active users in this tenant
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$maxUsers = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
if ($currentCount >= $maxUsers) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'users',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $maxUsers,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for a tenant (for dashboard display).
|
||||||
|
*/
|
||||||
|
public static function getUsageSummary(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
|
||||||
|
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
return [
|
||||||
|
'has_subscription' => false,
|
||||||
|
'plan' => 'none',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count companies
|
||||||
|
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$compStmt->execute([$tenantId]);
|
||||||
|
$companiesUsed = (int)$compStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Count users
|
||||||
|
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
|
||||||
|
$userStmt->execute([$tenantId]);
|
||||||
|
$usersUsed = (int)$userStmt->fetchColumn();
|
||||||
|
|
||||||
|
$invoicesUsed = (int)$sub['invoices_used_this_month'];
|
||||||
|
$invoicesLimit = (int)$sub['max_invoices_per_month'];
|
||||||
|
$companiesLimit = (int)$sub['max_companies'];
|
||||||
|
$usersLimit = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
// Check for pending payment request
|
||||||
|
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$pendingPayment = $stmt->fetch();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'has_subscription' => true,
|
||||||
|
'plan_id' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
|
||||||
|
'plan_price' => (float)($sub['plan_price'] ?? 0),
|
||||||
|
'status' => $sub['status'],
|
||||||
|
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
||||||
|
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
|
||||||
|
'pending_payment' => $pendingPayment ? [
|
||||||
|
'id' => $pendingPayment['id'],
|
||||||
|
'plan_id' => $pendingPayment['plan_id'],
|
||||||
|
'reference' => $pendingPayment['internal_reference']
|
||||||
|
] : null,
|
||||||
|
|
||||||
|
'invoices' => [
|
||||||
|
'used' => $invoicesUsed,
|
||||||
|
'limit' => $invoicesLimit,
|
||||||
|
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
|
||||||
|
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'companies' => [
|
||||||
|
'used' => $companiesUsed,
|
||||||
|
'limit' => $companiesLimit,
|
||||||
|
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
|
||||||
|
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'users' => [
|
||||||
|
'used' => $usersUsed,
|
||||||
|
'limit' => $usersLimit,
|
||||||
|
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
|
||||||
|
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
|
||||||
|
'period_start' => $sub['current_period_start'],
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
'trial_ends_at' => $sub['trial_ends_at'],
|
||||||
|
'days_remaining' => !empty($sub['current_period_end'])
|
||||||
|
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Middleware/RateLimitMiddleware.php
Normal file
78
app/Middleware/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rate Limiting Middleware (File-based, Race-Condition Safe)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
final class RateLimitMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* File-based rate limiter with file-lock to prevent race conditions.
|
||||||
|
* For multi-server deployments, replace with Redis.
|
||||||
|
*/
|
||||||
|
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||||
|
{
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$key = 'rl:' . md5($ip);
|
||||||
|
|
||||||
|
// 1. Try Redis first
|
||||||
|
$redis = \App\Core\Cache::getInstance();
|
||||||
|
if ($redis) {
|
||||||
|
try {
|
||||||
|
$count = $redis->get($key);
|
||||||
|
if ($count && (int)$count >= $maxRequests) {
|
||||||
|
header('Retry-After: ' . $timeWindow);
|
||||||
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$count) {
|
||||||
|
$redis->setex($key, $timeWindow, 1);
|
||||||
|
} else {
|
||||||
|
$redis->incr($key);
|
||||||
|
}
|
||||||
|
return; // Success with Redis
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback to file-based if Redis fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: File-based rate limiter (original logic)
|
||||||
|
$cacheDir = STORAGE_PATH . '/cache';
|
||||||
|
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||||
|
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||||
|
|
||||||
|
$fp = fopen($cacheFile, 'c+');
|
||||||
|
if ($fp === false) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
$now = time();
|
||||||
|
$content = stream_get_contents($fp);
|
||||||
|
$requests = [];
|
||||||
|
if (!empty($content)) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$requests = array_values(array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($requests) >= $maxRequests) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
header('Retry-After: ' . $timeWindow);
|
||||||
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests[] = $now;
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($requests));
|
||||||
|
} finally {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Middleware/RoleMiddleware.php
Normal file
97
app/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Role-Based Access Control (RBAC) Middleware
|
||||||
|
*
|
||||||
|
* Enforces role-based permissions on API endpoints.
|
||||||
|
* Must be called AFTER AuthMiddleware::check().
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* RoleMiddleware::require(['admin', 'super_admin']);
|
||||||
|
* RoleMiddleware::requireAny(['admin', 'accountant', 'super_admin']);
|
||||||
|
* RoleMiddleware::denyRole('viewer');
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
final class RoleMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Require the user to have ONE of the specified roles.
|
||||||
|
* Halts execution with 403 if the user doesn't have any of them.
|
||||||
|
*/
|
||||||
|
public static function require(array $allowedRoles, ?array $decoded = null): array
|
||||||
|
{
|
||||||
|
if (!$decoded) {
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
}
|
||||||
|
|
||||||
|
$userRole = $decoded['role'] ?? '';
|
||||||
|
|
||||||
|
if (!in_array($userRole, $allowedRoles, true)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ليس لديك صلاحية للوصول إلى هذا المورد',
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'required_roles' => $allowedRoles,
|
||||||
|
'your_role' => $userRole,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deny access to specific roles (blacklist approach).
|
||||||
|
*/
|
||||||
|
public static function deny(array $deniedRoles, ?array $decoded = null): array
|
||||||
|
{
|
||||||
|
if (!$decoded) {
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
}
|
||||||
|
|
||||||
|
$userRole = $decoded['role'] ?? '';
|
||||||
|
|
||||||
|
if (in_array($userRole, $deniedRoles, true)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ليس لديك صلاحية للوصول إلى هذا المورد',
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a super_admin.
|
||||||
|
*/
|
||||||
|
public static function isSuperAdmin(array $decoded): bool
|
||||||
|
{
|
||||||
|
return ($decoded['role'] ?? '') === 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is an admin or super_admin.
|
||||||
|
*/
|
||||||
|
public static function isAdmin(array $decoded): bool
|
||||||
|
{
|
||||||
|
return in_array($decoded['role'] ?? '', ['admin', 'super_admin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user can write (create/update/delete).
|
||||||
|
* Viewers are read-only.
|
||||||
|
*/
|
||||||
|
public static function canWrite(array $decoded): bool
|
||||||
|
{
|
||||||
|
return in_array($decoded['role'] ?? '', ['super_admin', 'admin', 'accountant'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/Services/GamificationService.php
Normal file
155
app/Services/GamificationService.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gamification Service — Badges & Points
|
||||||
|
*
|
||||||
|
* Awards points and badges based on user actions.
|
||||||
|
* Call GamificationService::award() from relevant endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
class GamificationService
|
||||||
|
{
|
||||||
|
// Points per action
|
||||||
|
private const POINTS = [
|
||||||
|
'invoice_uploaded' => 5,
|
||||||
|
'invoice_approved' => 10,
|
||||||
|
'jofotara_submitted' => 15,
|
||||||
|
'company_created' => 20,
|
||||||
|
'referral_registered' => 50,
|
||||||
|
'first_login' => 10,
|
||||||
|
'streak_7_days' => 30,
|
||||||
|
'streak_30_days' => 100,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Badge definitions
|
||||||
|
private const BADGES = [
|
||||||
|
'starter' => ['name' => 'بداية موفقة', 'icon' => '🌟', 'desc' => 'رفعت أول فاتورة', 'condition' => 'invoices >= 1'],
|
||||||
|
'active_10' => ['name' => 'نشيط', 'icon' => '🔥', 'desc' => '10 فواتير مرفوعة', 'condition' => 'invoices >= 10'],
|
||||||
|
'pro_50' => ['name' => 'محترف', 'icon' => '💎', 'desc' => '50 فاتورة مرفوعة', 'condition' => 'invoices >= 50'],
|
||||||
|
'master_200' => ['name' => 'خبير فوترة', 'icon' => '👑', 'desc' => '200 فاتورة مرفوعة', 'condition' => 'invoices >= 200'],
|
||||||
|
'jofotara_first' => ['name' => 'رسمي', 'icon' => '🏛️', 'desc' => 'أول إرسال لجوفوترا', 'condition' => 'submitted >= 1'],
|
||||||
|
'jofotara_50' => ['name' => 'فوترة ذهبية', 'icon' => '🏆', 'desc' => '50 فاتورة مرسلة لجوفوترا', 'condition' => 'submitted >= 50'],
|
||||||
|
'multi_company' => ['name' => 'مدير شركات', 'icon' => '🏢', 'desc' => 'تدير 3 شركات أو أكثر', 'condition' => 'companies >= 3'],
|
||||||
|
'referrer' => ['name' => 'سفير مُصادَق', 'icon' => '🤝', 'desc' => 'أحلت مستخدم جديد', 'condition' => 'referrals >= 1'],
|
||||||
|
'streak_week' => ['name' => 'مثابر', 'icon' => '📅', 'desc' => 'دخلت 7 أيام متتالية', 'condition' => 'streak >= 7'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award points for an action
|
||||||
|
*/
|
||||||
|
public static function award(string $userId, string $tenantId, string $action): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$points = self::POINTS[$action] ?? 0;
|
||||||
|
if ($points === 0) return;
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Add points
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO user_points (id, user_id, tenant_id, action, points, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, NOW())
|
||||||
|
")->execute([$userId, $tenantId, $action, $points]);
|
||||||
|
|
||||||
|
// Check for new badges
|
||||||
|
self::checkBadges($userId, $tenantId);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[Gamification] Award failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and award any earned badges
|
||||||
|
*/
|
||||||
|
private static function checkBadges(string $userId, string $tenantId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get user stats
|
||||||
|
$invoices = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ?")->execute([$tenantId])?->fetchColumn() ?: 0;
|
||||||
|
$submitted = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'submitted'")->execute([$tenantId])?->fetchColumn() ?: 0;
|
||||||
|
$companies = (int)$db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL")->execute([$tenantId])?->fetchColumn() ?: 0;
|
||||||
|
$referrals = (int)$db->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id = ?")->execute([$userId])?->fetchColumn() ?: 0;
|
||||||
|
|
||||||
|
// Get existing badges
|
||||||
|
$existingStmt = $db->prepare("SELECT badge_key FROM user_badges WHERE user_id = ?");
|
||||||
|
$existingStmt->execute([$userId]);
|
||||||
|
$existing = $existingStmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$stats = compact('invoices', 'submitted', 'companies', 'referrals');
|
||||||
|
|
||||||
|
foreach (self::BADGES as $key => $badge) {
|
||||||
|
if (in_array($key, $existing)) continue;
|
||||||
|
|
||||||
|
if (self::evaluateCondition($badge['condition'], $stats)) {
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO user_badges (id, user_id, tenant_id, badge_key, badge_name, badge_icon, earned_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, NOW())
|
||||||
|
")->execute([$userId, $tenantId, $key, $badge['name'], $badge['icon']]);
|
||||||
|
|
||||||
|
// Notify user
|
||||||
|
SmartNotifications::send($tenantId, $userId, 'badge_earned',
|
||||||
|
"{$badge['icon']} شارة جديدة: {$badge['name']}!",
|
||||||
|
$badge['desc'],
|
||||||
|
['badge_key' => $key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple condition evaluator
|
||||||
|
*/
|
||||||
|
private static function evaluateCondition(string $condition, array $stats): bool
|
||||||
|
{
|
||||||
|
if (preg_match('/(\w+)\s*>=\s*(\d+)/', $condition, $m)) {
|
||||||
|
$field = $m[1];
|
||||||
|
$value = (int)$m[2];
|
||||||
|
return ($stats[$field] ?? 0) >= $value;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's gamification profile
|
||||||
|
*/
|
||||||
|
public static function getProfile(string $userId, string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Total points
|
||||||
|
$pointsStmt = $db->prepare("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?");
|
||||||
|
$pointsStmt->execute([$userId]);
|
||||||
|
$totalPoints = (int)$pointsStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
$badgesStmt = $db->prepare("SELECT badge_key, badge_name, badge_icon, earned_at FROM user_badges WHERE user_id = ? ORDER BY earned_at DESC");
|
||||||
|
$badgesStmt->execute([$userId]);
|
||||||
|
$badges = $badgesStmt->fetchAll();
|
||||||
|
|
||||||
|
// Level (every 100 points = 1 level)
|
||||||
|
$level = max(1, (int)floor($totalPoints / 100) + 1);
|
||||||
|
$levelNames = ['', 'مبتدئ', 'ناشط', 'متقدم', 'خبير', 'أسطورة', 'سيد الفوترة'];
|
||||||
|
$levelName = $levelNames[min($level, count($levelNames) - 1)] ?? 'أسطورة';
|
||||||
|
|
||||||
|
// Progress to next level
|
||||||
|
$pointsInLevel = $totalPoints % 100;
|
||||||
|
$progressPercent = $pointsInLevel;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_points' => $totalPoints,
|
||||||
|
'level' => $level,
|
||||||
|
'level_name' => $levelName,
|
||||||
|
'progress_percent' => $progressPercent,
|
||||||
|
'badges' => $badges,
|
||||||
|
'badges_count' => count($badges),
|
||||||
|
'available_badges' => count(self::BADGES),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/Services/InvoiceExtractionService.php
Normal file
173
app/Services/InvoiceExtractionService.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
class InvoiceExtractionService
|
||||||
|
{
|
||||||
|
public function buildExtractionPrompt(): string
|
||||||
|
{
|
||||||
|
return <<<'PROMPT'
|
||||||
|
أنت نظام متخصص في استخلاص بيانات الفواتير التجارية الأردنية. مهمتك الوحيدة: استخراج البيانات بدقة تامة وتصنيف الضرائب بشكل صحيح.
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## قواعد اللغة والأرقام (إلزامية):
|
||||||
|
════════════════════════════════════════
|
||||||
|
- إذا كانت الفاتورة بالعربية: أبقِ أسماء السلع والعناوين بالعربية دون ترجمة
|
||||||
|
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية دون ترجمة
|
||||||
|
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
|
||||||
|
- المبالغ دائماً بـ 3 أرقام عشرية (مثال: 15.000 وليس 15 أو 15.00)
|
||||||
|
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## التحقق الرياضي والفواتير الشاملة للضريبة (إلزامي):
|
||||||
|
════════════════════════════════════════
|
||||||
|
- معظم فواتير التجزئة والسوبرماركت (POS) في الأردن تكون "شاملة للضريبة" (Tax Inclusive).
|
||||||
|
- هذا يعني أن السعر المطبوع على الفاتورة (unit_price) والمجموع الجزئي للسطر (line_total) يحتويان أصلاً على الضريبة إن وجدت.
|
||||||
|
- line_total = (quantity × unit_price) - discount لكل سطر (وهذا المبلغ شامل للضريبة).
|
||||||
|
- subtotal = مجموع كل line_total
|
||||||
|
- grand_total = subtotal - discount_total (يجب أن يتطابق تماماً مع المبلغ الكلي المطلوب من العميل في الفاتورة).
|
||||||
|
- tax_amount = مجموع الضرائب المحسوبة عكسياً من line_total (أو كما هي مذكورة صراحةً في أسفل الفاتورة). إياك أن تضيف tax_amount فوق subtotal إذا كانت الفاتورة شاملة للضريبة.
|
||||||
|
- إذا كانت الفاتورة من النوع النادر غير الشامل للضريبة (Tax Exclusive): grand_total = subtotal - discount_total + tax_amount
|
||||||
|
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: يجب أن تعطي الأولوية القصوى لتطابق `grand_total` مع الرقم المطبوع الذي تم دفعه فعلياً، وسجِّل أي ملاحظات في validation_warnings.
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## جدول الضرائب الأردنية (مرجعك الإلزامي):
|
||||||
|
════════════════════════════════════════
|
||||||
|
|
||||||
|
### نسبة 0.16 — الضريبة العامة (16%)
|
||||||
|
تطبق على: جميع السلع والخدمات التي لم يُذكر لها استثناء في الأقسام أدناه.
|
||||||
|
|
||||||
|
### نسبة 0.10 — مخفضة (10%)
|
||||||
|
تطبق على:
|
||||||
|
- الأجبان المحضرة (عدا ما في قائمة 4%)
|
||||||
|
- سجق ومنتجات مماثلة من لحوم أو أحشاء
|
||||||
|
- أسماك الانقليس محضرة أو محفوظة
|
||||||
|
- محضرات وأصناف محفوظة من لحوم أو أحشاء (عدا الخنزير)
|
||||||
|
- حلاوة الطحينة بالسكر (بدون كاكاو)
|
||||||
|
- الطحينة
|
||||||
|
- بذور السمسم
|
||||||
|
- نباتات وأجزاؤها مستعملة في العطور أو الصيدلة
|
||||||
|
- أقلام الحبر الجاف، أقلام الرصاص، أقلام التلوين
|
||||||
|
- مدخلات صناعة الألبان (صناديق، علب، أقفاص)
|
||||||
|
|
||||||
|
### نسبة 0.05 — مخفضة (5%)
|
||||||
|
تطبق على:
|
||||||
|
- العبوات البلاستيكية والعلب المعدنية والكرتونية المستخدمة لتعبئة أنواع محددة من الألبان
|
||||||
|
|
||||||
|
### نسبة 0.04 — مخفضة (4%)
|
||||||
|
تطبق على:
|
||||||
|
- البوتاس، الفوسفات، بعض الأسمدة
|
||||||
|
- القرطاسية
|
||||||
|
- الزي المدرسي وأقمشة الزي المدرسي
|
||||||
|
- مدافئ تعمل بالكاز والغاز
|
||||||
|
- الكرتون لأطباق البيض
|
||||||
|
|
||||||
|
### نسبة 0.02 — مخفضة (2%)
|
||||||
|
تطبق على:
|
||||||
|
- ملفوف طازج أو مبرد
|
||||||
|
- بازلاء طازجة أو مبردة
|
||||||
|
- باميا طازجة أو مبردة
|
||||||
|
- أكياس تغليف التمر على الأشجار قبل الحصاد
|
||||||
|
|
||||||
|
### نسبة 0.00 — صفري (0%) — فئة: "Z" — يُسمح بخصم ضريبة المدخلات
|
||||||
|
تطبق على:
|
||||||
|
- اللحوم (عدا ما في قائمة 10%)
|
||||||
|
- الأسماك (عدا الانقليس)
|
||||||
|
- المحضرات الخاصة لتغذية الأطفال والمعوقين والمحضرات الطبية
|
||||||
|
- أغطية بلاستيك للزراعة (الملش الزراعي)
|
||||||
|
- لوازم شبكات الري (أنابيب، فواصل، أكواع)
|
||||||
|
- صناديق وأقفاص خشبية لتعبئة المنتجات الزراعية
|
||||||
|
- بيض الطيور الطازج لصناعة اللقاحات البيطرية
|
||||||
|
- بصيلات ودرنات وجذور في طور البيات
|
||||||
|
- هياكل البيوت الزراعية من حديد أو صلب
|
||||||
|
- آلات وأدوات البستنة ومحادل الملاعب
|
||||||
|
- نباتات وجذور الهندباء
|
||||||
|
- زيوت النفط الخام والغازات البترولية (عدا زيوت التشحيم)
|
||||||
|
- الأدوية واللقاحات البيطرية
|
||||||
|
- أسمدة NPK، اليوريا، الأمونياك
|
||||||
|
|
||||||
|
### معفاة كلياً — فئة: "E" — لا يُسمح بخصم ضريبة المدخلات
|
||||||
|
تطبق على:
|
||||||
|
- دقيق الحنطة
|
||||||
|
- عدس وحمص يابس والبقوليات
|
||||||
|
- زيت الزيتون غير المعدل كيماوياً
|
||||||
|
- سكر مكرر (عدا سكر القصب)
|
||||||
|
- الشاي الأسود (عبوات ≤ 3 كغ)
|
||||||
|
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (مثل حليب نيدو)
|
||||||
|
- الألبان (اللبن الرائب، الشنينة، لبن حمودة، الخ) والأجبان البيضاء العادية.
|
||||||
|
- بيض المائدة
|
||||||
|
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
|
||||||
|
- أجهزة الهواتف الذكية
|
||||||
|
- الطاقة الكهربائية
|
||||||
|
- النقود الورقية والمعدنية
|
||||||
|
- حافلات نقل 10 أشخاص أو أكثر
|
||||||
|
- سيارات عمرها 5 سنوات فأكثر
|
||||||
|
- السيارات الكهربائية والهجينة
|
||||||
|
|
||||||
|
### ضريبة خاصة — فئة: "O"
|
||||||
|
تطبق على: الإسمنت، التبغ، المشروبات الكحولية، السيارات الجديدة، المحروقات، زيوت التشحيم
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## قواعد تصنيف الضريبة لكل سطر:
|
||||||
|
════════════════════════════════════════
|
||||||
|
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ).
|
||||||
|
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات.
|
||||||
|
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
|
||||||
|
4. tax_category: استخدم "standard" للخاضعة (16% أو مخفضة)، "zero_rated" للصفري، "exempt" للمعفاة، "special" للخاصة
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## تصنيف طريقة الدفع:
|
||||||
|
════════════════════════════════════════
|
||||||
|
- "013" = نقداً (cash, كاش, نقد)
|
||||||
|
- "010" = بطاقة ائتمانية أو مدى (credit card, debit card, بطاقة)
|
||||||
|
- "001" = تحويل بنكي (bank transfer, حوالة بنكية, شيك)
|
||||||
|
- إذا لم تُذكر → افتراضي "013"
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
|
||||||
|
════════════════════════════════════════
|
||||||
|
{
|
||||||
|
"invoices": [
|
||||||
|
{
|
||||||
|
"invoice_number": "string | null",
|
||||||
|
"invoice_date": "YYYY-MM-DD | null",
|
||||||
|
"invoice_type": "cash | credit",
|
||||||
|
"payment_method_code": "013 | 010 | 001",
|
||||||
|
"ubl_type_code": "388",
|
||||||
|
"supplier": {
|
||||||
|
"name": "string | null",
|
||||||
|
"tin": "string | null",
|
||||||
|
"address": "string | null"
|
||||||
|
},
|
||||||
|
"buyer": {
|
||||||
|
"name": "string | null",
|
||||||
|
"tin": "string | null",
|
||||||
|
"national_id": "string | null"
|
||||||
|
},
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"line_number": 1,
|
||||||
|
"description": "string",
|
||||||
|
"quantity": 1.000,
|
||||||
|
"unit_price": 0.000,
|
||||||
|
"discount": 0.000,
|
||||||
|
"tax_rate": 0.16,
|
||||||
|
"tax_category": "standard | zero_rated | exempt | special",
|
||||||
|
"tax_exempt_reason": "string | null",
|
||||||
|
"line_total": 0.000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtotal": 0.000,
|
||||||
|
"discount_total": 0.000,
|
||||||
|
"tax_amount": 0.000,
|
||||||
|
"grand_total": 0.000,
|
||||||
|
"currency_code": "JOD",
|
||||||
|
"math_verified": true,
|
||||||
|
"validation_warnings": [],
|
||||||
|
"ai_confidence": 0.95
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
app/Services/InvoiceProcessor.php
Normal file
247
app/Services/InvoiceProcessor.php
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
|
|
||||||
|
class InvoiceProcessor
|
||||||
|
{
|
||||||
|
private static function log(string $msg): void
|
||||||
|
{
|
||||||
|
$line = "[" . date('Y-m-d H:i:s') . "] [InvoiceProcessor] " . $msg . "\n";
|
||||||
|
@file_put_contents(STORAGE_PATH . '/logs/worker.log', $line, FILE_APPEND);
|
||||||
|
// Also echo for CLI/terminal usage
|
||||||
|
if (php_sapi_name() === 'cli') {
|
||||||
|
echo $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a single invoice queue item by its ID.
|
||||||
|
*/
|
||||||
|
public static function processQueueItem(int $queueId): bool
|
||||||
|
{
|
||||||
|
self::log("Starting processQueueItem($queueId)");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::log("FATAL: Cannot connect to DB: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the queue item and its batch info
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT q.*, b.tenant_id, b.company_id, b.uploaded_by, b.total_images
|
||||||
|
FROM invoice_processing_queue q
|
||||||
|
JOIN invoice_batches b ON q.batch_id = b.id
|
||||||
|
WHERE q.id = ? AND q.status = 'pending'
|
||||||
|
");
|
||||||
|
$stmt->execute([$queueId]);
|
||||||
|
$item = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$item) {
|
||||||
|
self::log("Queue ID $queueId: Not found or not pending. Skipping.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchId = $item['batch_id'];
|
||||||
|
$tenantId = $item['tenant_id'];
|
||||||
|
$companyId = $item['company_id'];
|
||||||
|
$userId = $item['uploaded_by'];
|
||||||
|
$imagePath = $item['image_path'];
|
||||||
|
|
||||||
|
self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId");
|
||||||
|
|
||||||
|
// Mark as processing
|
||||||
|
$db->prepare("UPDATE invoice_processing_queue SET status = 'processing' WHERE id = ?")->execute([$queueId]);
|
||||||
|
|
||||||
|
// Check file exists
|
||||||
|
if (!file_exists($imagePath)) {
|
||||||
|
self::log("Queue ID $queueId: FILE NOT FOUND: $imagePath");
|
||||||
|
$db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'File not found' WHERE id = ?")->execute([$queueId]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::log("Queue ID $queueId: File exists (" . filesize($imagePath) . " bytes). Starting AI extraction...");
|
||||||
|
|
||||||
|
$mimeType = mime_content_type($imagePath) ?: 'image/jpeg';
|
||||||
|
$fileContent = file_get_contents($imagePath);
|
||||||
|
$base64Data = base64_encode($fileContent);
|
||||||
|
|
||||||
|
// AI Extraction (this takes ~5-15 seconds)
|
||||||
|
$extracted = AI::extractInvoiceData($base64Data, $mimeType);
|
||||||
|
|
||||||
|
if (!$extracted) {
|
||||||
|
self::log("Queue ID $queueId: AI extraction returned NULL (failed).");
|
||||||
|
$db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'AI failed to extract data from image' WHERE id = ?")->execute([$queueId]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::log("Queue ID $queueId: AI extraction successful. Saving to DB...");
|
||||||
|
|
||||||
|
// Save to database in a transaction
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
|
||||||
|
$supplierTin = $extracted['supplier']['tin'] ?? '';
|
||||||
|
$invoiceNum = $extracted['invoice_number'] ?? '';
|
||||||
|
$invoiceDate = $extracted['invoice_date'] ?? '';
|
||||||
|
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoices (
|
||||||
|
id, tenant_id, company_id, uploaded_by, original_file_path, status,
|
||||||
|
invoice_number, invoice_date, invoice_type, invoice_category,
|
||||||
|
supplier_tin, supplier_name, supplier_address,
|
||||||
|
buyer_tin, buyer_name, buyer_national_id,
|
||||||
|
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 'extracted',
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$invoiceId, $tenantId, $companyId, $userId, $imagePath,
|
||||||
|
$invoiceNum, $validDate, $extracted['invoice_type'] ?? 'cash', $extracted['invoice_category'] ?? 'simplified',
|
||||||
|
Encryption::encrypt($supplierTin), Encryption::encrypt($extracted['supplier']['name'] ?? ''), Encryption::encrypt($extracted['supplier']['address'] ?? ''),
|
||||||
|
Encryption::encrypt($extracted['buyer']['tin'] ?? ''), Encryption::encrypt($extracted['buyer']['name'] ?? ''), Encryption::encrypt($extracted['buyer']['national_id'] ?? ''),
|
||||||
|
$extracted['subtotal'] ?? 0, $extracted['tax_amount'] ?? 0, $extracted['discount_total'] ?? 0, $extracted['grand_total'] ?? 0, $extracted['currency_code'] ?? 'JOD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Save invoice line items
|
||||||
|
if (!empty($extracted['lines'])) {
|
||||||
|
$lineStmt = $db->prepare("
|
||||||
|
INSERT INTO invoice_lines (
|
||||||
|
id, invoice_id, line_number, description,
|
||||||
|
quantity, unit_price, tax_rate, tax_amount,
|
||||||
|
discount_amount, net_total, line_total, tax_category
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
");
|
||||||
|
foreach ($extracted['lines'] as $idx => $line) {
|
||||||
|
$quantity = (float)($line['quantity'] ?? 1);
|
||||||
|
$unitPrice = (float)($line['unit_price'] ?? 0);
|
||||||
|
$taxRate = (float)($line['tax_rate'] ?? 0);
|
||||||
|
$discount = (float)($line['discount'] ?? $line['discount_amount'] ?? 0);
|
||||||
|
$subtotal = $quantity * $unitPrice;
|
||||||
|
$taxAmount = (float)($line['tax_amount'] ?? ($subtotal * $taxRate));
|
||||||
|
$netTotal = (float)($line['net_total'] ?? ($line['line_total'] ?? ($subtotal + $taxAmount - $discount)));
|
||||||
|
|
||||||
|
$lineStmt->execute([
|
||||||
|
vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)),
|
||||||
|
$invoiceId,
|
||||||
|
$line['line_number'] ?? ($idx + 1),
|
||||||
|
$line['description'] ?? '',
|
||||||
|
$quantity,
|
||||||
|
$unitPrice,
|
||||||
|
$taxRate,
|
||||||
|
$taxAmount,
|
||||||
|
$discount,
|
||||||
|
$netTotal,
|
||||||
|
$netTotal, // line_total
|
||||||
|
$line['tax_category'] ?? 'standard'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark queue item done
|
||||||
|
$db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, processed_at = NOW() WHERE id = ?")->execute([$invoiceId, $queueId]);
|
||||||
|
// Update batch progress
|
||||||
|
$db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?")->execute([$batchId]);
|
||||||
|
// Increment quota
|
||||||
|
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
self::log("Queue ID $queueId: ✓ Invoice $invoiceId created and committed.");
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($db->inTransaction()) {
|
||||||
|
$db->rollBack();
|
||||||
|
}
|
||||||
|
self::log("Queue ID $queueId: DB ERROR: " . $e->getMessage());
|
||||||
|
try {
|
||||||
|
$db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = ? WHERE id = ?")->execute([$e->getMessage(), $queueId]);
|
||||||
|
} catch (\Throwable $e2) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if entire batch is complete
|
||||||
|
self::checkBatchCompletion($batchId);
|
||||||
|
|
||||||
|
// Progress/Completion Push
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$currentBatch = $stmt->fetch();
|
||||||
|
if ($currentBatch) {
|
||||||
|
$notifier = new NotificationService();
|
||||||
|
// Send data notification with invoice_id for auto-navigation
|
||||||
|
$notifier->sendDataNotification($currentBatch['uploaded_by'], [
|
||||||
|
'type' => 'invoice_processed',
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'processed' => $currentBatch['processed_images'],
|
||||||
|
'total' => $currentBatch['total_images']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $pushErr) {
|
||||||
|
self::log("Queue ID $queueId: Push notification failed (non-critical): " . $pushErr->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::log("Queue ID $queueId: UNHANDLED EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function checkBatchCompletion(string $batchId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($batch && $batch['processed_images'] >= $batch['total_images']) {
|
||||||
|
$db->prepare("UPDATE invoice_batches SET status = 'done', completed_at = NOW() WHERE id = ?")->execute([$batchId]);
|
||||||
|
self::log("Batch $batchId: COMPLETE ({$batch['processed_images']}/{$batch['total_images']})");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get the last invoice_id for this batch for completion navigation
|
||||||
|
$invStmt = $db->prepare("SELECT id FROM invoices WHERE original_file_path IN (SELECT image_path FROM invoice_processing_queue WHERE batch_id = ?) ORDER BY created_at DESC LIMIT 1");
|
||||||
|
$invStmt->execute([$batchId]);
|
||||||
|
$lastInvoiceId = $invStmt->fetchColumn();
|
||||||
|
|
||||||
|
$notifier = new NotificationService();
|
||||||
|
$notifier->sendNotification(
|
||||||
|
$batch['uploaded_by'],
|
||||||
|
"اكتملت معالجة الدفعة",
|
||||||
|
"تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.",
|
||||||
|
[
|
||||||
|
'type' => 'batch_complete',
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'invoice_id' => $lastInvoiceId ?: ''
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::log("Batch $batchId: Completion notification failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::log("Batch $batchId: checkBatchCompletion error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
app/Services/NotificationService.php
Normal file
276
app/Services/NotificationService.php
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Firebase Notification Service (FCM HTTP v1)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
class NotificationService
|
||||||
|
{
|
||||||
|
private string $projectId;
|
||||||
|
private string $serviceAccountPath;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->serviceAccountPath = env('FIREBASE_SERVICE_ACCOUNT_PATH', APP_PATH . '/config/firebase-service-account.json');
|
||||||
|
|
||||||
|
// Auto-detect Project ID from Service Account JSON to prevent RESOURCE_PROJECT_INVALID
|
||||||
|
if (file_exists($this->serviceAccountPath)) {
|
||||||
|
$sa = json_decode(file_get_contents($this->serviceAccountPath), true);
|
||||||
|
$this->projectId = $sa['project_id'] ?? env('FIREBASE_PROJECT_ID', '');
|
||||||
|
} else {
|
||||||
|
$this->projectId = env('FIREBASE_PROJECT_ID', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push notification to a specific user or device
|
||||||
|
*/
|
||||||
|
public function sendNotification(string $userId, string $title, string $body, array $data = [], ?string $deviceId = null): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 1. Get push tokens for the user
|
||||||
|
if ($deviceId) {
|
||||||
|
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL");
|
||||||
|
$stmt->execute([$userId, $deviceId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($tokens)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Save notification to database (Single direct insert)
|
||||||
|
$stmt = $db->prepare("SELECT tenant_id FROM users WHERE id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$tenantId = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($tenantId) {
|
||||||
|
$stmt = $db->prepare("INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at) VALUES (UUID(), ?, ?, 'system', ?, ?, ?, NOW())");
|
||||||
|
$stmt->execute([$tenantId, $userId, $title, $body, json_encode($data)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send to each token
|
||||||
|
$successCount = 0;
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ($this->dispatchToFcm($token, $title, $body, $data)) {
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $successCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a data-only (silent) notification to update background state (e.g., progress)
|
||||||
|
*/
|
||||||
|
public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
if ($deviceId) {
|
||||||
|
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL");
|
||||||
|
$stmt->execute([$userId, $deviceId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
if (empty($tokens)) return false;
|
||||||
|
|
||||||
|
$successCount = 0;
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ($this->dispatchToFcm($token, null, null, $data)) {
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $successCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch notification to Firebase via HTTP v1 API
|
||||||
|
*/
|
||||||
|
private function dispatchToFcm(string $token, ?string $title, ?string $body, array $data): bool
|
||||||
|
{
|
||||||
|
if (!file_exists($this->serviceAccountPath)) {
|
||||||
|
error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accessToken = $this->getAccessToken();
|
||||||
|
if (!$accessToken) return false;
|
||||||
|
|
||||||
|
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
|
||||||
|
|
||||||
|
$message = [
|
||||||
|
'token' => $token,
|
||||||
|
'data' => array_map('strval', $data),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($title || $body) {
|
||||||
|
$message['notification'] = [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
];
|
||||||
|
$message['android'] = [
|
||||||
|
'priority' => 'high',
|
||||||
|
'notification' => [
|
||||||
|
'sound' => 'default',
|
||||||
|
'channel_id' => 'high_importance_channel'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
$message['apns'] = [
|
||||||
|
'payload' => [
|
||||||
|
'aps' => [
|
||||||
|
'sound' => 'default',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Silent push / Live Activity Update
|
||||||
|
$message['android'] = [
|
||||||
|
'priority' => 'high'
|
||||||
|
];
|
||||||
|
$message['apns'] = [
|
||||||
|
'headers' => [
|
||||||
|
'apns-priority' => '5',
|
||||||
|
'apns-push-type' => 'background'
|
||||||
|
],
|
||||||
|
'payload' => [
|
||||||
|
'aps' => [
|
||||||
|
'content-available' => 1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// If the data contains live activity update markers, adjust headers for iOS ActivityKit
|
||||||
|
if (isset($data['type']) && $data['type'] === 'batch_progress') {
|
||||||
|
$message['apns']['headers']['apns-push-type'] = 'liveactivity';
|
||||||
|
$message['apns']['headers']['apns-priority'] = '10';
|
||||||
|
$message['apns']['payload']['aps']['content-state'] = $data;
|
||||||
|
$message['apns']['payload']['aps']['timestamp'] = time();
|
||||||
|
$message['apns']['payload']['aps']['event'] = 'update';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = ['message' => $message];
|
||||||
|
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Authorization: Bearer ' . $accessToken,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("[NotificationService] FCM Send Error ($httpCode): " . $response);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth2 Access Token for Firebase using Service Account JWT
|
||||||
|
* Self-contained: no external libraries needed.
|
||||||
|
*/
|
||||||
|
private function getAccessToken(): ?string
|
||||||
|
{
|
||||||
|
// Check cache first (token is valid for 1 hour, we cache for 50 min)
|
||||||
|
$cacheFile = STORAGE_PATH . '/cache/fcm_token.json';
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||||
|
if ($cached && ($cached['expires_at'] ?? 0) > time()) {
|
||||||
|
return $cached['access_token'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($this->serviceAccountPath)) {
|
||||||
|
error_log("[NotificationService] Firebase service account file missing");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sa = json_decode(file_get_contents($this->serviceAccountPath), true);
|
||||||
|
if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) {
|
||||||
|
error_log("[NotificationService] Invalid service account JSON");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build JWT
|
||||||
|
$now = time();
|
||||||
|
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
|
||||||
|
$payload = json_encode([
|
||||||
|
'iss' => $sa['client_email'],
|
||||||
|
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||||
|
'aud' => 'https://oauth2.googleapis.com/token',
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '=');
|
||||||
|
$b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||||
|
$signingInput = $b64Header . '.' . $b64Payload;
|
||||||
|
|
||||||
|
$privateKey = openssl_pkey_get_private($sa['private_key']);
|
||||||
|
if (!$privateKey) {
|
||||||
|
error_log("[NotificationService] Failed to parse private key");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
|
$b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
||||||
|
$jwt = $signingInput . '.' . $b64Signature;
|
||||||
|
|
||||||
|
// Exchange JWT for access token
|
||||||
|
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query([
|
||||||
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
'assertion' => $jwt,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("[NotificationService] Token exchange failed ($httpCode): $response");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenData = json_decode($response, true);
|
||||||
|
$accessToken = $tokenData['access_token'] ?? null;
|
||||||
|
|
||||||
|
if ($accessToken) {
|
||||||
|
// Cache for 50 minutes
|
||||||
|
@file_put_contents($cacheFile, json_encode([
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'expires_at' => $now + 3000,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/Services/SmartNotifications.php
Normal file
163
app/Services/SmartNotifications.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Smart Notification Triggers
|
||||||
|
*
|
||||||
|
* Centralized service for sending intelligent, context-aware notifications.
|
||||||
|
* Call these methods from relevant endpoints (e.g., after invoice upload, approval, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
class SmartNotifications
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Notify admin when quota usage reaches 80%
|
||||||
|
*/
|
||||||
|
public static function checkQuotaWarning(string $tenantId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) return;
|
||||||
|
|
||||||
|
$usage = ($sub['max_invoices_per_month'] > 0)
|
||||||
|
? ($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if ($usage >= 80 && $usage < 100) {
|
||||||
|
// Find admin user
|
||||||
|
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
|
||||||
|
$adminStmt->execute([$tenantId]);
|
||||||
|
$adminId = $adminStmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($adminId) {
|
||||||
|
self::send($tenantId, $adminId, 'quota_warning',
|
||||||
|
'⚠️ اقتربت من حد الباقة',
|
||||||
|
'استخدمت ' . round($usage) . '% من حصة الفواتير الشهرية. فكّر بالترقية لتجنب التوقف.',
|
||||||
|
['usage_percent' => round($usage)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[SmartNotifications] Quota warning failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user when invoice is approved
|
||||||
|
*/
|
||||||
|
public static function invoiceApproved(string $tenantId, string $uploaderId, string $invoiceId, string $invoiceNumber): void
|
||||||
|
{
|
||||||
|
self::send($tenantId, $uploaderId, 'invoice_approved',
|
||||||
|
'✅ تم اعتماد الفاتورة',
|
||||||
|
"الفاتورة رقم {$invoiceNumber} تم اعتمادها وهي جاهزة للإرسال لجوفوترا.",
|
||||||
|
['invoice_id' => $invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify when JoFotara submission succeeds
|
||||||
|
*/
|
||||||
|
public static function jofotaraSuccess(string $tenantId, string $userId, string $invoiceId, string $uuid): void
|
||||||
|
{
|
||||||
|
self::send($tenantId, $userId, 'jofotara_success',
|
||||||
|
'🎉 تم إرسال الفاتورة لجوفوترا',
|
||||||
|
"الفاتورة أُرسلت بنجاح. UUID: {$uuid}",
|
||||||
|
['invoice_id' => $invoiceId, 'jofotara_uuid' => $uuid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify when JoFotara submission fails
|
||||||
|
*/
|
||||||
|
public static function jofotaraRejected(string $tenantId, string $userId, string $invoiceId, string $error): void
|
||||||
|
{
|
||||||
|
self::send($tenantId, $userId, 'jofotara_rejected',
|
||||||
|
'❌ رُفضت الفاتورة من جوفوترا',
|
||||||
|
"الفاتورة لم تُقبل: {$error}",
|
||||||
|
['invoice_id' => $invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify admin about pending invoices (daily digest)
|
||||||
|
*/
|
||||||
|
public static function pendingInvoicesDigest(string $tenantId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'extracted'");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$count = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($count === 0) return;
|
||||||
|
|
||||||
|
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
|
||||||
|
$adminStmt->execute([$tenantId]);
|
||||||
|
$adminId = $adminStmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($adminId) {
|
||||||
|
self::send($tenantId, $adminId, 'pending_digest',
|
||||||
|
"📋 لديك {$count} فاتورة بانتظار المراجعة",
|
||||||
|
"هناك {$count} فاتورة مستخرجة لم تُراجع بعد. راجعها واعتمدها لإرسالها لجوفوترا.",
|
||||||
|
['pending_count' => $count]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[SmartNotifications] Pending digest failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome notification for new users
|
||||||
|
*/
|
||||||
|
public static function welcomeUser(string $tenantId, string $userId, string $name): void
|
||||||
|
{
|
||||||
|
self::send($tenantId, $userId, 'welcome',
|
||||||
|
"مرحباً بك في مُصادَق، {$name}! 🎉",
|
||||||
|
'ابدأ برفع أول فاتورة — صوّرها أو ارفع PDF والذكاء الاصطناعي يكمل الباقي.',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core send method — writes to DB (push handled by NotificationService)
|
||||||
|
*/
|
||||||
|
private static function send(string $tenantId, string $userId, string $type, string $title, string $body, array $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Deduplicate: don't send same type within 1 hour
|
||||||
|
$dedup = $db->prepare("
|
||||||
|
SELECT id FROM notifications
|
||||||
|
WHERE user_id = ? AND type = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$dedup->execute([$userId, $type]);
|
||||||
|
if ($dedup->fetch()) return;
|
||||||
|
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
")->execute([$tenantId, $userId, $type, $title, $body, json_encode($data, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
// Try push notification (non-blocking)
|
||||||
|
try {
|
||||||
|
$notifService = new NotificationService();
|
||||||
|
$notifService->sendNotification($userId, $title, $body, $data);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Push failure is non-critical
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[SmartNotifications] Send failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Services/WhatsAppProxyService.php
Normal file
78
app/Services/WhatsAppProxyService.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Proxy Service
|
||||||
|
*
|
||||||
|
* Used to send WhatsApp messages (like OTPs) via Intaleq proxy bots.
|
||||||
|
*/
|
||||||
|
class WhatsAppProxyService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* قائمة السيرفرات المتاحة
|
||||||
|
*/
|
||||||
|
private array $servers = [
|
||||||
|
//"https://botmasa.intaleq.xyz/send", // mayar
|
||||||
|
//"https://botmasa2.intaleq.xyz/send", // shad
|
||||||
|
//"https://bootride.intaleq.xyz/send", // ramat bus
|
||||||
|
// "https://bot3.intaleq.xyz/send", // shahd
|
||||||
|
"https://bot5.intaleq.xyz/send", // bot5 from postman
|
||||||
|
//"https://whatsapp.tripz-egypt.com/send" // tripz
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إرسال رسالة واتساب
|
||||||
|
*
|
||||||
|
* @param string $to رقم الهاتف
|
||||||
|
* @param string $message نص الرسالة
|
||||||
|
* @return bool نجاح الإرسال
|
||||||
|
*/
|
||||||
|
public function sendMessage(string $to, string $message): array
|
||||||
|
{
|
||||||
|
if (empty($this->servers)) {
|
||||||
|
return ['success' => false, 'error' => 'No servers available.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// اختيار سيرفر عشوائي من القائمة المتاحة لتوزيع الحمل
|
||||||
|
$url = $this->servers[array_rand($this->servers)];
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
"to" => $to,
|
||||||
|
"message" => [
|
||||||
|
"text" => $message
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$curl = curl_init();
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => "POST",
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Content-Type: application/json"
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 15, // مهلة 15 ثانية للطلب
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$err = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
if ($err) {
|
||||||
|
return ['success' => false, 'error' => $err, 'url' => $url];
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseData = json_decode($response, true);
|
||||||
|
$isSuccess = isset($responseData['success']) && $responseData['success'] === true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $isSuccess,
|
||||||
|
'response' => $responseData,
|
||||||
|
'raw_response' => $response,
|
||||||
|
'url' => $url,
|
||||||
|
'payload' => $payload
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,29 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// 1. Basic Constants
|
// 1. Basic Constants
|
||||||
define('ROOT_PATH', dirname(__DIR__, 2));
|
define('ROOT_PATH', realpath(dirname(__DIR__, 2)));
|
||||||
define('APP_PATH', ROOT_PATH . '/app');
|
define('APP_PATH', ROOT_PATH . '/app');
|
||||||
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
||||||
|
|
||||||
// 2. Load Environment & Helpers FIRST
|
// 2. Load Environment & Helpers FIRST
|
||||||
require_once APP_PATH . '/bootstrap/env.php';
|
require_once APP_PATH . '/bootstrap/env.php';
|
||||||
require_once APP_PATH . '/helpers/helpers.php';
|
require_once APP_PATH . '/helpers/helpers.php';
|
||||||
|
require_once APP_PATH . '/helpers/pagination.php';
|
||||||
|
|
||||||
|
// Load Composer Autoloader
|
||||||
|
$vendorAutoload = ROOT_PATH . '/vendor/autoload.php';
|
||||||
|
if (file_exists($vendorAutoload)) {
|
||||||
|
require_once $vendorAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-healing Storage
|
||||||
|
$dirs = ['/cache', '/logs', '/invoices', '/exports'];
|
||||||
|
foreach ($dirs as $d) {
|
||||||
|
$path = STORAGE_PATH . $d;
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
mkdir($path, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Error Reporting (Secure for production)
|
// 3. Error Reporting (Secure for production)
|
||||||
if (env('APP_DEBUG', 'false') === 'true') {
|
if (env('APP_DEBUG', 'false') === 'true') {
|
||||||
@@ -47,12 +63,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|||||||
|
|
||||||
// 5. Security Headers
|
// 5. Security Headers
|
||||||
header("X-Content-Type-Options: nosniff");
|
header("X-Content-Type-Options: nosniff");
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: SAMEORIGIN");
|
||||||
header("X-XSS-Protection: 1; mode=block");
|
header("X-XSS-Protection: 1; mode=block");
|
||||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS
|
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
|
||||||
|
header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
|
||||||
|
|
||||||
// 6. Intelligent Autoloader (Case-Insensitive for directories)
|
// CSP: Allow self + known CDNs (Tailwind, Alpine, Google Fonts)
|
||||||
|
$csp = "default-src 'self'; "
|
||||||
|
. "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; "
|
||||||
|
. "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||||
|
. "font-src 'self' https://fonts.gstatic.com; "
|
||||||
|
. "img-src 'self' data:; "
|
||||||
|
. "connect-src 'self';";
|
||||||
|
header("Content-Security-Policy: $csp");
|
||||||
|
|
||||||
|
// 6. Request body size limit (2MB for JSON, file uploads handled separately)
|
||||||
|
if (isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 2 * 1024 * 1024) {
|
||||||
|
if (empty($_FILES)) { // Don't block file uploads
|
||||||
|
http_response_code(413);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Request body too large'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
$prefix = 'App\\';
|
$prefix = 'App\\';
|
||||||
$base_dir = APP_PATH . '/';
|
$base_dir = APP_PATH . '/';
|
||||||
@@ -64,7 +100,7 @@ spl_autoload_register(function ($class) {
|
|||||||
|
|
||||||
$parts = explode('\\', $relative_class);
|
$parts = explode('\\', $relative_class);
|
||||||
$filename = array_pop($parts) . '.php';
|
$filename = array_pop($parts) . '.php';
|
||||||
$dir = strtolower(implode('/', $parts));
|
$dir = implode('/', $parts); // No strtolower — preserves PascalCase on Linux
|
||||||
|
|
||||||
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
|
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
|
||||||
|
|
||||||
|
|||||||
80
app/config/plans.php
Normal file
80
app/config/plans.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Subscription Plans Configuration (Fallback)
|
||||||
|
*
|
||||||
|
* This is used as a fallback when the database subscription_plans
|
||||||
|
* table is not available. The database is the source of truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'free' => [
|
||||||
|
'id' => 'free',
|
||||||
|
'name_ar' => 'التجربة المجانية',
|
||||||
|
'name_en' => 'Free Trial',
|
||||||
|
'max_companies' => 1,
|
||||||
|
'max_invoices_month' => 15,
|
||||||
|
'max_users' => 1,
|
||||||
|
'price_jod' => 0.00,
|
||||||
|
'price_monthly_jod' => 0.00,
|
||||||
|
'price_annual_jod' => 0.00,
|
||||||
|
'ai_features' => true,
|
||||||
|
'jofotara_enabled' => true,
|
||||||
|
'badge_color' => 'gray',
|
||||||
|
'description_ar' => 'للتجربة الأولية — شركة واحدة و15 فاتورة شهرياً',
|
||||||
|
'features' => [
|
||||||
|
'استخراج الفواتير بالذكاء الاصطناعي',
|
||||||
|
'الربط المباشر مع جوفوترة',
|
||||||
|
'شركة واحدة فقط',
|
||||||
|
'15 فاتورة شهرياً',
|
||||||
|
'مستخدم واحد',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'basic' => [
|
||||||
|
'id' => 'basic',
|
||||||
|
'name_ar' => 'الباقة الأساسية',
|
||||||
|
'name_en' => 'Basic Plan',
|
||||||
|
'max_companies' => 3,
|
||||||
|
'max_invoices_month' => 500,
|
||||||
|
'max_users' => 2,
|
||||||
|
'price_jod' => 15.00, // Default legacy price
|
||||||
|
'price_monthly_jod' => 15.00,
|
||||||
|
'price_annual_jod' => 120.00,
|
||||||
|
'ai_features' => true,
|
||||||
|
'jofotara_enabled' => true,
|
||||||
|
'badge_color' => 'blue',
|
||||||
|
'description_ar' => 'للمحاسبين المستقلين والشركات الصغيرة — 3 شركات',
|
||||||
|
'features' => [
|
||||||
|
'استخراج الفواتير بالذكاء الاصطناعي',
|
||||||
|
'الربط المباشر مع جوفوترة',
|
||||||
|
'حتى 3 شركات (بدلاً من واحدة)',
|
||||||
|
'500 فاتورة شهرياً (سخية جداً)',
|
||||||
|
'مستخدمين اثنين',
|
||||||
|
'دعم فني عبر الواتساب',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pro' => [
|
||||||
|
'id' => 'pro',
|
||||||
|
'name_ar' => 'الباقة الاحترافية',
|
||||||
|
'name_en' => 'Pro Plan',
|
||||||
|
'max_companies' => 9999,
|
||||||
|
'max_invoices_month' => 3000,
|
||||||
|
'max_users' => 5,
|
||||||
|
'price_jod' => 35.00, // Default legacy price
|
||||||
|
'price_monthly_jod' => 35.00,
|
||||||
|
'price_annual_jod' => 290.00,
|
||||||
|
'ai_features' => true,
|
||||||
|
'jofotara_enabled' => true,
|
||||||
|
'badge_color' => 'gold',
|
||||||
|
'is_popular' => true,
|
||||||
|
'description_ar' => 'للمكاتب الكبيرة والموزعين — حجم عمل ضخم',
|
||||||
|
'features' => [
|
||||||
|
'استخراج الفواتير بالذكاء الاصطناعي',
|
||||||
|
'الربط المباشر مع جوفوترة',
|
||||||
|
'عدد شركات غير محدود',
|
||||||
|
'3,000 فاتورة شهرياً',
|
||||||
|
'5 مستخدمين',
|
||||||
|
'API كامل لتطبيق الهاتف',
|
||||||
|
'مدير حساب مخصص',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Simple Data Validator
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
final class Validator
|
|
||||||
{
|
|
||||||
public static function validate(array $data, array $rules): array
|
|
||||||
{
|
|
||||||
$errors = [];
|
|
||||||
foreach ($rules as $field => $rule) {
|
|
||||||
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
|
|
||||||
$errors[$field] = "The {$field} field is required.";
|
|
||||||
}
|
|
||||||
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
|
|
||||||
$errors[$field] = "The {$field} must be a valid email address.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $errors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
139
app/cron/diagnose.php
Normal file
139
app/cron/diagnose.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Diagnostic Script — Run on server to verify processing works
|
||||||
|
*
|
||||||
|
* Usage: php app/cron/diagnose.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../bootstrap/init.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
echo "=== Musadaq Processing Diagnostics ===\n";
|
||||||
|
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||||
|
echo "PHP: " . PHP_VERSION . "\n";
|
||||||
|
echo "SAPI: " . php_sapi_name() . "\n\n";
|
||||||
|
|
||||||
|
// 1. Check DB connection
|
||||||
|
echo "--- Database ---\n";
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
echo " ✓ Database connected\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo " ✗ Database FAILED: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check pending queue items
|
||||||
|
echo "\n--- Queue Status ---\n";
|
||||||
|
$stmt = $db->query("SELECT status, COUNT(*) as cnt FROM invoice_processing_queue GROUP BY status");
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if (empty($rows)) {
|
||||||
|
echo " (empty — no items in queue at all)\n";
|
||||||
|
} else {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo " {$r['status']}: {$r['cnt']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check batch statuses
|
||||||
|
echo "\n--- Batch Status ---\n";
|
||||||
|
$stmt = $db->query("SELECT status, COUNT(*) as cnt FROM invoice_batches GROUP BY status");
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if (empty($rows)) {
|
||||||
|
echo " (empty — no batches)\n";
|
||||||
|
} else {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo " {$r['status']}: {$r['cnt']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for stuck items (processing but no worker)
|
||||||
|
echo "\n--- Stuck Items (processing for >5 minutes) ---\n";
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT q.id, q.batch_id, q.status, q.image_path, q.created_at, q.error_message
|
||||||
|
FROM invoice_processing_queue q
|
||||||
|
WHERE q.status IN ('pending', 'processing')
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$stuck = $stmt->fetchAll();
|
||||||
|
if (empty($stuck)) {
|
||||||
|
echo " (none — all clear)\n";
|
||||||
|
} else {
|
||||||
|
foreach ($stuck as $s) {
|
||||||
|
$exists = file_exists($s['image_path']) ? '✓ file exists' : '✗ FILE MISSING';
|
||||||
|
echo " ID={$s['id']} | Status={$s['status']} | $exists\n";
|
||||||
|
echo " Path: {$s['image_path']}\n";
|
||||||
|
if ($s['error_message']) echo " Error: {$s['error_message']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check lock file
|
||||||
|
echo "\n--- Lock File ---\n";
|
||||||
|
$lockFile = STORAGE_PATH . '/logs/process_batches.lock';
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
$age = time() - filemtime($lockFile);
|
||||||
|
$content = trim(file_get_contents($lockFile));
|
||||||
|
echo " ⚠ Lock file EXISTS (age: {$age}s, content: $content)\n";
|
||||||
|
if ($age > 300) {
|
||||||
|
echo " → This lock is STALE. Removing...\n";
|
||||||
|
@unlink($lockFile);
|
||||||
|
echo " ✓ Removed.\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ✓ No lock file (good)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Check key files
|
||||||
|
echo "\n--- Key Files ---\n";
|
||||||
|
$files = [
|
||||||
|
'InvoiceProcessor' => APP_PATH . '/Services/InvoiceProcessor.php',
|
||||||
|
'AI' => APP_PATH . '/Core/AI.php',
|
||||||
|
'process_batches' => APP_PATH . '/cron/process_batches.php',
|
||||||
|
'worker.log' => STORAGE_PATH . '/logs/worker.log',
|
||||||
|
];
|
||||||
|
foreach ($files as $name => $path) {
|
||||||
|
if (file_exists($path)) {
|
||||||
|
echo " ✓ $name: $path (" . filesize($path) . " bytes)\n";
|
||||||
|
} else {
|
||||||
|
echo " ✗ $name: MISSING — $path\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Check Gemini API key
|
||||||
|
echo "\n--- Configuration ---\n";
|
||||||
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
|
echo " GEMINI_API_KEY: " . ($apiKey ? "✓ Set (" . strlen($apiKey) . " chars)" : "✗ MISSING!") . "\n";
|
||||||
|
echo " APP_DEBUG: " . env('APP_DEBUG', 'false') . "\n";
|
||||||
|
echo " fastcgi_finish_request: " . (function_exists('fastcgi_finish_request') ? '✓ Available' : '✗ Not available (CLI mode)') . "\n";
|
||||||
|
|
||||||
|
// 8. Show last lines of worker.log
|
||||||
|
echo "\n--- Last 20 lines of worker.log ---\n";
|
||||||
|
$workerLog = STORAGE_PATH . '/logs/worker.log';
|
||||||
|
if (file_exists($workerLog)) {
|
||||||
|
$lines = file($workerLog);
|
||||||
|
$last = array_slice($lines, -20);
|
||||||
|
foreach ($last as $line) {
|
||||||
|
echo " " . rtrim($line) . "\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (worker.log does not exist yet)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Try to reset any stuck 'processing' items back to 'pending'
|
||||||
|
echo "\n--- Fix Stuck Items? ---\n";
|
||||||
|
$stmt = $db->query("SELECT COUNT(*) FROM invoice_processing_queue WHERE status = 'processing'");
|
||||||
|
$stuckCount = (int)$stmt->fetchColumn();
|
||||||
|
if ($stuckCount > 0) {
|
||||||
|
echo " Found $stuckCount items stuck in 'processing' state.\n";
|
||||||
|
$db->query("UPDATE invoice_processing_queue SET status = 'pending' WHERE status = 'processing'");
|
||||||
|
echo " ✓ Reset them to 'pending' so they can be reprocessed.\n";
|
||||||
|
} else {
|
||||||
|
echo " ✓ No stuck items.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== Diagnostics Complete ===\n";
|
||||||
|
echo "Next step: Run 'php app/cron/process_batches.php' to process pending items.\n";
|
||||||
91
app/cron/process_batches.php
Normal file
91
app/cron/process_batches.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Cron Worker for AI Invoice Extraction
|
||||||
|
*
|
||||||
|
* Designed to run via cron every minute: * * * * *
|
||||||
|
* Processes ALL pending items in the queue, then EXITS.
|
||||||
|
* NO infinite loop. NO lock file issues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../bootstrap/init.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\InvoiceProcessor;
|
||||||
|
|
||||||
|
// Simple lock: prevent overlapping runs
|
||||||
|
$lockFile = STORAGE_PATH . '/logs/process_batches.lock';
|
||||||
|
|
||||||
|
// Check if lock file exists and is stale (older than 5 minutes = dead process)
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
$lockAge = time() - filemtime($lockFile);
|
||||||
|
if ($lockAge > 300) {
|
||||||
|
// Stale lock from a crashed process - remove it
|
||||||
|
@unlink($lockFile);
|
||||||
|
workerLog("Removed stale lock file (age: {$lockAge}s)");
|
||||||
|
} else {
|
||||||
|
workerLog("Worker already running (lock age: {$lockAge}s). Exiting.");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lock
|
||||||
|
file_put_contents($lockFile, getmypid() . "\n" . date('c'));
|
||||||
|
|
||||||
|
function workerLog(string $msg): void {
|
||||||
|
$line = "[" . date('Y-m-d H:i:s') . "] " . $msg . "\n";
|
||||||
|
echo $line;
|
||||||
|
// Also write to dedicated log file
|
||||||
|
@file_put_contents(STORAGE_PATH . '/logs/worker.log', $line, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLog("=== Musadaq AI Worker Started ===");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$processed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
// Get ALL pending items (no infinite loop!)
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id FROM invoice_processing_queue
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 20
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$items = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($items)) {
|
||||||
|
workerLog("No pending items. Exiting.");
|
||||||
|
} else {
|
||||||
|
workerLog("Found " . count($items) . " pending item(s).");
|
||||||
|
|
||||||
|
foreach ($items as $queueId) {
|
||||||
|
workerLog("Processing Queue ID: $queueId ...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$success = InvoiceProcessor::processQueueItem((int)$queueId);
|
||||||
|
if ($success) {
|
||||||
|
$processed++;
|
||||||
|
workerLog(" ✓ Queue ID $queueId processed successfully.");
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
workerLog(" ✗ Queue ID $queueId failed (returned false).");
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
workerLog(" ✗ Queue ID $queueId EXCEPTION: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLog("=== Worker Done: $processed success, $failed failed ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
workerLog("FATAL ERROR: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||||
|
} finally {
|
||||||
|
// ALWAYS remove lock file
|
||||||
|
@unlink($lockFile);
|
||||||
|
}
|
||||||
@@ -38,3 +38,19 @@ if (!function_exists('dd')) {
|
|||||||
die();
|
die();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!function_exists('safe_error')) {
|
||||||
|
/**
|
||||||
|
* Log exception details securely and return a safe user-facing message.
|
||||||
|
* Full details go to error_log; users only see a generic Arabic message.
|
||||||
|
*
|
||||||
|
* @param \Throwable $e The caught exception
|
||||||
|
* @param string $context Short label for the endpoint (e.g. 'invoices/upload')
|
||||||
|
* @param string $userMsg Arabic message shown to the user
|
||||||
|
* @param int $code HTTP status code
|
||||||
|
*/
|
||||||
|
function safe_error(\Throwable $e, string $context, string $userMsg = 'حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.', int $code = 500): void {
|
||||||
|
error_log("[{$context}] " . get_class($e) . ': ' . $e->getMessage() . ' | ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
json_error($userMsg, $code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
59
app/helpers/pagination.php
Normal file
59
app/helpers/pagination.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Pagination Helper
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $pagination = paginate_params(); // extracts page, per_page from query string
|
||||||
|
* // Use $pagination['limit'] and $pagination['offset'] in SQL
|
||||||
|
* // Wrap results: json_paginated($items, $totalCount, $pagination);
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('paginate_params')) {
|
||||||
|
/**
|
||||||
|
* Extract pagination parameters from the query string.
|
||||||
|
*
|
||||||
|
* @param int $defaultPerPage Default items per page
|
||||||
|
* @param int $maxPerPage Maximum allowed per page (prevents abuse)
|
||||||
|
* @return array ['page' => int, 'per_page' => int, 'limit' => int, 'offset' => int]
|
||||||
|
*/
|
||||||
|
function paginate_params(int $defaultPerPage = 25, int $maxPerPage = 100): array
|
||||||
|
{
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$perPage = min($maxPerPage, max(1, (int)($_GET['per_page'] ?? $defaultPerPage)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'limit' => $perPage,
|
||||||
|
'offset' => $offset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('json_paginated')) {
|
||||||
|
/**
|
||||||
|
* Return a paginated JSON response with metadata.
|
||||||
|
*
|
||||||
|
* @param array $items The current page of results
|
||||||
|
* @param int $total Total count of all matching records
|
||||||
|
* @param array $pagination Output from paginate_params()
|
||||||
|
* @param string $message Optional success message
|
||||||
|
*/
|
||||||
|
function json_paginated(array $items, int $total, array $pagination, string $message = 'Success'): void
|
||||||
|
{
|
||||||
|
$totalPages = (int)ceil($total / max(1, $pagination['per_page']));
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'items' => $items,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $pagination['page'],
|
||||||
|
'per_page' => $pagination['per_page'],
|
||||||
|
'total' => $total,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'has_next' => $pagination['page'] < $totalPages,
|
||||||
|
'has_prev' => $pagination['page'] > 1,
|
||||||
|
],
|
||||||
|
], $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,17 @@ final class AuthMiddleware
|
|||||||
$decoded = JWT::decode($token, $secret);
|
$decoded = JWT::decode($token, $secret);
|
||||||
|
|
||||||
if (!$decoded) {
|
if (!$decoded) {
|
||||||
json_error('Unauthorized: Invalid or expired token', 401);
|
// Check if it's specifically expired if your JWT class supports it,
|
||||||
|
// otherwise just send the standard 401 with a code.
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'انتهت صلاحية الجلسة',
|
||||||
|
'code' => 'TOKEN_EXPIRED',
|
||||||
|
'redirect'=> '/login.php'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
|
|||||||
295
app/middleware/QuotaMiddleware.php
Normal file
295
app/middleware/QuotaMiddleware.php
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Quota Enforcement Middleware
|
||||||
|
*
|
||||||
|
* Checks tenant subscription limits before allowing resource creation.
|
||||||
|
* Automatically resets monthly counters when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
|
final class QuotaMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the tenant can upload more invoices this month.
|
||||||
|
* Automatically resets the counter if the billing period has ended.
|
||||||
|
*
|
||||||
|
* @return array The current subscription data (for UI display)
|
||||||
|
*/
|
||||||
|
public static function checkInvoiceQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
|
if ($sub === false || $sub === null) {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Fetch subscription with plan info
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($sub) {
|
||||||
|
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
if ($sub['status'] === 'cancelled') {
|
||||||
|
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sub['status'] === 'past_due') {
|
||||||
|
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reset period counter if billing period has ended
|
||||||
|
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
||||||
|
$newStart = date('Y-m-d H:i:s');
|
||||||
|
$cycle = $sub['billing_cycle'] ?? 'annual';
|
||||||
|
$interval = ($cycle === 'monthly') ? '+1 month' : '+1 year';
|
||||||
|
$newEnd = date('Y-m-d H:i:s', strtotime($interval));
|
||||||
|
|
||||||
|
$resetStmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = 0,
|
||||||
|
current_period_start = ?,
|
||||||
|
current_period_end = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
|
||||||
|
|
||||||
|
$sub['invoices_used_this_month'] = 0;
|
||||||
|
$sub['current_period_start'] = $newStart;
|
||||||
|
$sub['current_period_end'] = $newEnd;
|
||||||
|
|
||||||
|
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invoice quota
|
||||||
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
|
||||||
|
if ($used >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
||||||
|
'quota_type' => 'invoices',
|
||||||
|
'used' => $used,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the monthly invoice counter after a successful upload.
|
||||||
|
*/
|
||||||
|
public static function incrementInvoiceUsage(string $tenantId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = invoices_used_this_month + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
Cache::delete("quota_sub_{$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more companies.
|
||||||
|
*/
|
||||||
|
public static function checkCompanyQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active companies
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM companies
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL)
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$limit = (int)$sub['max_companies'];
|
||||||
|
|
||||||
|
if ($currentCount >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'companies',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more users.
|
||||||
|
*/
|
||||||
|
public static function checkUserQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active users in this tenant
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$maxUsers = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
if ($currentCount >= $maxUsers) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'users',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $maxUsers,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for a tenant (for dashboard display).
|
||||||
|
*/
|
||||||
|
public static function getUsageSummary(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
|
||||||
|
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
return [
|
||||||
|
'has_subscription' => false,
|
||||||
|
'plan' => 'none',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count companies
|
||||||
|
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$compStmt->execute([$tenantId]);
|
||||||
|
$companiesUsed = (int)$compStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Count users
|
||||||
|
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
|
||||||
|
$userStmt->execute([$tenantId]);
|
||||||
|
$usersUsed = (int)$userStmt->fetchColumn();
|
||||||
|
|
||||||
|
$invoicesUsed = (int)$sub['invoices_used_this_month'];
|
||||||
|
$invoicesLimit = (int)$sub['max_invoices_per_month'];
|
||||||
|
$companiesLimit = (int)$sub['max_companies'];
|
||||||
|
$usersLimit = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
// Check for pending payment request
|
||||||
|
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$pendingPayment = $stmt->fetch();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'has_subscription' => true,
|
||||||
|
'plan_id' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
|
||||||
|
'plan_price' => (float)($sub['plan_price'] ?? 0),
|
||||||
|
'status' => $sub['status'],
|
||||||
|
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
||||||
|
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
|
||||||
|
'pending_payment' => $pendingPayment ? [
|
||||||
|
'id' => $pendingPayment['id'],
|
||||||
|
'plan_id' => $pendingPayment['plan_id'],
|
||||||
|
'reference' => $pendingPayment['internal_reference']
|
||||||
|
] : null,
|
||||||
|
|
||||||
|
'invoices' => [
|
||||||
|
'used' => $invoicesUsed,
|
||||||
|
'limit' => $invoicesLimit,
|
||||||
|
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
|
||||||
|
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'companies' => [
|
||||||
|
'used' => $companiesUsed,
|
||||||
|
'limit' => $companiesLimit,
|
||||||
|
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
|
||||||
|
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'users' => [
|
||||||
|
'used' => $usersUsed,
|
||||||
|
'limit' => $usersLimit,
|
||||||
|
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
|
||||||
|
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
|
||||||
|
'period_start' => $sub['current_period_start'],
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
'trial_ends_at' => $sub['trial_ends_at'],
|
||||||
|
'days_remaining' => !empty($sub['current_period_end'])
|
||||||
|
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,54 +15,61 @@ final class RateLimitMiddleware
|
|||||||
*/
|
*/
|
||||||
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||||
{
|
{
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$key = 'rl:' . md5($ip);
|
||||||
|
|
||||||
|
// 1. Try Redis first
|
||||||
|
$redis = \App\Core\Cache::getInstance();
|
||||||
|
if ($redis) {
|
||||||
|
try {
|
||||||
|
$count = $redis->get($key);
|
||||||
|
if ($count && (int)$count >= $maxRequests) {
|
||||||
|
header('Retry-After: ' . $timeWindow);
|
||||||
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$count) {
|
||||||
|
$redis->setex($key, $timeWindow, 1);
|
||||||
|
} else {
|
||||||
|
$redis->incr($key);
|
||||||
|
}
|
||||||
|
return; // Success with Redis
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback to file-based if Redis fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: File-based rate limiter (original logic)
|
||||||
$cacheDir = STORAGE_PATH . '/cache';
|
$cacheDir = STORAGE_PATH . '/cache';
|
||||||
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||||
|
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||||
|
|
||||||
if (!is_dir($cacheDir)) {
|
|
||||||
mkdir($cacheDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// M2 Fix: Use exclusive file lock to prevent race condition
|
|
||||||
$fp = fopen($cacheFile, 'c+');
|
$fp = fopen($cacheFile, 'c+');
|
||||||
if ($fp === false) {
|
if ($fp === false) return;
|
||||||
// If we can't open the file, fail open (don't block all users)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
|
flock($fp, LOCK_EX);
|
||||||
|
$now = time();
|
||||||
$now = time();
|
$content = stream_get_contents($fp);
|
||||||
$content = stream_get_contents($fp);
|
|
||||||
$requests = [];
|
$requests = [];
|
||||||
|
|
||||||
if (!empty($content)) {
|
if (!empty($content)) {
|
||||||
$decoded = json_decode($content, true);
|
$decoded = json_decode($content, true);
|
||||||
if (is_array($decoded)) {
|
if (is_array($decoded)) {
|
||||||
// Keep only requests within the time window
|
$requests = array_values(array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)));
|
||||||
$requests = array_values(
|
|
||||||
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($requests) >= $maxRequests) {
|
if (count($requests) >= $maxRequests) {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|
||||||
header('Retry-After: ' . $timeWindow);
|
header('Retry-After: ' . $timeWindow);
|
||||||
json_error('Too Many Requests. Please slow down.', 429);
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this request
|
|
||||||
$requests[] = $now;
|
$requests[] = $now;
|
||||||
|
|
||||||
// Write updated data back
|
|
||||||
ftruncate($fp, 0);
|
ftruncate($fp, 0);
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
fwrite($fp, json_encode($requests));
|
fwrite($fp, json_encode($requests));
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|||||||
93
app/modules_app/academy/articles.php
Normal file
93
app/modules_app/academy/articles.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Musadaq Academy — Educational Content
|
||||||
|
* GET /v1/academy/articles
|
||||||
|
* GET /v1/academy/articles?category=tax
|
||||||
|
*
|
||||||
|
* Returns curated accounting and tax educational articles.
|
||||||
|
* Content is stored in-code for MVP, can be migrated to DB later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
$category = $_GET['category'] ?? null;
|
||||||
|
$search = $_GET['search'] ?? null;
|
||||||
|
|
||||||
|
// In-code content library (MVP — migrate to DB when content grows)
|
||||||
|
$articles = [
|
||||||
|
[
|
||||||
|
'id' => 'tax-101',
|
||||||
|
'category' => 'tax',
|
||||||
|
'title' => 'دليل ضريبة المبيعات الأردنية الشامل',
|
||||||
|
'summary' => 'كل ما تحتاج معرفته عن نسب ضريبة المبيعات في الأردن: العامة (16%)، المخفضة (4% و 8%)، والمعفاة.',
|
||||||
|
'content' => "## نسب ضريبة المبيعات في الأردن\n\n### النسبة العامة: 16%\nتُطبق على معظم السلع والخدمات.\n\n### النسبة المخفضة: 4%\n- الأدوية\n- المستلزمات الطبية\n\n### النسبة المخفضة: 8%\n- الخدمات السياحية\n- بعض المواد الغذائية المصنعة\n\n### معفاة من الضريبة (0%)\n- الخبز\n- الحليب\n- التعليم\n- الخدمات الصحية\n\n> ملاحظة: هذه المعلومات للإرشاد فقط. راجع دائرة ضريبة الدخل والمبيعات للتفاصيل الرسمية.",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '🏛️',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'jofotara-guide',
|
||||||
|
'category' => 'jofotara',
|
||||||
|
'title' => 'كيف تربط شركتك بمنظومة جوفوترا',
|
||||||
|
'summary' => 'خطوات تسجيل شركتك والحصول على Client ID و Secret Key من منظومة الفوترة الإلكترونية.',
|
||||||
|
'content' => "## خطوات الربط بجوفوترا\n\n### 1. التسجيل في المنظومة\n- ادخل على portal.jofotara.gov.jo\n- سجّل بالرقم الضريبي لشركتك\n\n### 2. الحصول على المفاتيح\n- من لوحة التحكم، اختر \"إدارة التطبيقات\"\n- أنشئ تطبيق جديد\n- انسخ Client ID و Secret Key\n\n### 3. الربط في مُصادَق\n- افتح إعدادات الشركة\n- الصق Client ID و Secret Key\n- اضغط \"اختبار الاتصال\"\n\n> بعد الربط، يمكنك إرسال الفواتير لجوفوترا بضغطة واحدة!",
|
||||||
|
'reading_time' => 4,
|
||||||
|
'icon' => '🔗',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'invoice-types',
|
||||||
|
'category' => 'invoicing',
|
||||||
|
'title' => 'أنواع الفواتير الإلكترونية في الأردن',
|
||||||
|
'summary' => 'الفرق بين فاتورة المبيعات، الإشعار الدائن، والإشعار المدين حسب UBL 2.1.',
|
||||||
|
'content' => "## أنواع الفواتير\n\n### 1. فاتورة مبيعات (Invoice)\nالنوع الأساسي — تُصدر عند بيع سلعة أو خدمة.\n\n### 2. إشعار دائن (Credit Note)\nيُصدر لتعديل فاتورة سابقة بالتخفيض (مرتجعات أو خصومات).\n\n### 3. إشعار مدين (Debit Note)\nيُصدر لتعديل فاتورة سابقة بالزيادة.\n\n### متطلبات UBL 2.1\n- كل فاتورة يجب أن تحتوي على رقم ضريبي صحيح\n- التاريخ بصيغة ISO\n- تفصيل البنود مع الكمية والسعر",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '📄',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'ai-tips',
|
||||||
|
'category' => 'tips',
|
||||||
|
'title' => 'نصائح للحصول على أفضل نتائج من الذكاء الاصطناعي',
|
||||||
|
'summary' => 'كيف تصوّر الفاتورة لتحصل على استخراج دقيق بنسبة 99%.',
|
||||||
|
'content' => "## نصائح التصوير\n\n### ✅ افعل:\n- صوّر الفاتورة كاملة مع الحواف\n- تأكد من الإضاءة الجيدة\n- ضع الفاتورة على سطح مسطح\n- صوّر من الأعلى مباشرة (لا بزاوية)\n\n### ❌ لا تفعل:\n- لا تصوّر جزء من الفاتورة فقط\n- لا تصوّر فاتورة مطوية أو مجعدة\n- لا تصوّر في إضاءة خافتة\n- لا ترفع صور أقل من 300x300 بكسل\n\n### 💡 نصيحة إضافية:\nاستخدم ميزة الـ Batch Scan لتصوير عدة فواتير دفعة واحدة!",
|
||||||
|
'reading_time' => 2,
|
||||||
|
'icon' => '💡',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'security-guide',
|
||||||
|
'category' => 'security',
|
||||||
|
'title' => 'كيف يحمي مُصادَق بياناتك',
|
||||||
|
'summary' => 'نظرة على تقنيات التشفير والحماية المستخدمة في المنصة.',
|
||||||
|
'content' => "## حماية بياناتك\n\n### تشفير AES-256-GCM\nكل البيانات الحساسة (أسماء، أرقام ضريبية، مفاتيح API) مشفرة بأقوى معيار تشفير.\n\n### فصل البيانات (Multi-Tenancy)\nكل مكتب محاسبي معزول تماماً — لا يمكن لأي مكتب رؤية بيانات مكتب آخر.\n\n### مصادقة ثنائية\nتسجيل الدخول يتطلب OTP عبر واتساب بالإضافة لكلمة المرور.\n\n### HMAC Signature\nكل طلب API يتم التحقق من سلامته عبر توقيع رقمي.",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '🔒',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if ($category) {
|
||||||
|
$articles = array_values(array_filter($articles, fn($a) => $a['category'] === $category));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($search) {
|
||||||
|
$searchLower = mb_strtolower($search);
|
||||||
|
$articles = array_values(array_filter($articles, fn($a) =>
|
||||||
|
str_contains(mb_strtolower($a['title']), $searchLower) ||
|
||||||
|
str_contains(mb_strtolower($a['summary']), $searchLower)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = [
|
||||||
|
['key' => 'tax', 'name' => 'ضرائب', 'icon' => '🏛️'],
|
||||||
|
['key' => 'jofotara', 'name' => 'جوفوترا', 'icon' => '🔗'],
|
||||||
|
['key' => 'invoicing', 'name' => 'فوترة', 'icon' => '📄'],
|
||||||
|
['key' => 'tips', 'name' => 'نصائح', 'icon' => '💡'],
|
||||||
|
['key' => 'security', 'name' => 'أمان', 'icon' => '🔒'],
|
||||||
|
];
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'articles' => $articles,
|
||||||
|
'categories' => $categories,
|
||||||
|
'total' => count($articles),
|
||||||
|
], 'أكاديمية مُصادَق');
|
||||||
67
app/modules_app/ai-usage/log.php
Normal file
67
app/modules_app/ai-usage/log.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Usage Log Endpoint
|
||||||
|
* GET /api/v1/ai-usage/log
|
||||||
|
*
|
||||||
|
* Returns paginated log of all AI requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = min(50, max(10, (int) ($_GET['per_page'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$isSuperAdmin = $decoded['role'] === 'super_admin';
|
||||||
|
|
||||||
|
$tenantCondition = $isSuperAdmin ? "" : "WHERE a.tenant_id = ?";
|
||||||
|
$params = $isSuperAdmin ? [] : [$tenantId];
|
||||||
|
|
||||||
|
// Count
|
||||||
|
$countSql = "SELECT COUNT(*) FROM ai_usage_log a $tenantCondition";
|
||||||
|
$countStmt = $db->prepare($countSql);
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int) $countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Fetch
|
||||||
|
$sql = "SELECT
|
||||||
|
a.id, a.action_type, a.model_name,
|
||||||
|
a.prompt_tokens, a.completion_tokens, a.total_tokens,
|
||||||
|
a.estimated_cost, a.created_at
|
||||||
|
FROM ai_usage_log a
|
||||||
|
$tenantCondition
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $perPage OFFSET $offset";
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$logs = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Translate action types
|
||||||
|
$actionLabels = [
|
||||||
|
'invoice_extraction' => 'استخراج فاتورة',
|
||||||
|
'voice_transcribe' => 'تحويل صوت لنص',
|
||||||
|
'voice_intent' => 'تحليل أمر صوتي',
|
||||||
|
'report_generation' => 'توليد تقرير',
|
||||||
|
'chatbot' => 'محادثة ذكية',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($logs as &$log) {
|
||||||
|
$log['action_label'] = $actionLabels[$log['action_type']] ?? $log['action_type'];
|
||||||
|
$log['estimated_cost'] = round((float) $log['estimated_cost'], 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'logs' => $logs,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => ceil($total / $perPage),
|
||||||
|
],
|
||||||
|
]);
|
||||||
103
app/modules_app/ai-usage/stats.php
Normal file
103
app/modules_app/ai-usage/stats.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Usage Stats Endpoint
|
||||||
|
* GET /api/v1/ai-usage/stats
|
||||||
|
*
|
||||||
|
* Returns AI token consumption stats for the current tenant.
|
||||||
|
* Super admin sees system-wide; admin sees their tenant only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$period = $_GET['period'] ?? 'month'; // day, week, month, all
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$isSuperAdmin = $decoded['role'] === 'super_admin';
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
$dateCondition = match ($period) {
|
||||||
|
'day' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
|
||||||
|
'week' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
|
||||||
|
'month' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
|
||||||
|
default => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
$tenantCondition = $isSuperAdmin ? "" : "AND a.tenant_id = ?";
|
||||||
|
$params = $isSuperAdmin ? [] : [$tenantId];
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
$sql = "SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(a.prompt_tokens), 0) as total_prompt_tokens,
|
||||||
|
COALESCE(SUM(a.completion_tokens), 0) as total_completion_tokens,
|
||||||
|
COALESCE(SUM(a.total_tokens), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(a.estimated_cost), 0) as total_cost
|
||||||
|
FROM ai_usage_log a
|
||||||
|
WHERE 1=1 $tenantCondition $dateCondition";
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$totals = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Breakdown by action type
|
||||||
|
$sql2 = "SELECT
|
||||||
|
a.action_type,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(a.total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(a.estimated_cost), 0) as cost
|
||||||
|
FROM ai_usage_log a
|
||||||
|
WHERE 1=1 $tenantCondition $dateCondition
|
||||||
|
GROUP BY a.action_type
|
||||||
|
ORDER BY tokens DESC";
|
||||||
|
|
||||||
|
$stmt2 = $db->prepare($sql2);
|
||||||
|
$stmt2->execute($params);
|
||||||
|
$breakdown = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Breakdown by model
|
||||||
|
$sql3 = "SELECT
|
||||||
|
a.model_name,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(a.total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(a.estimated_cost), 0) as cost
|
||||||
|
FROM ai_usage_log a
|
||||||
|
WHERE 1=1 $tenantCondition $dateCondition
|
||||||
|
GROUP BY a.model_name
|
||||||
|
ORDER BY tokens DESC";
|
||||||
|
|
||||||
|
$stmt3 = $db->prepare($sql3);
|
||||||
|
$stmt3->execute($params);
|
||||||
|
$modelBreakdown = $stmt3->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Daily trend (last 30 days)
|
||||||
|
$sql4 = "SELECT
|
||||||
|
DATE(a.created_at) as date,
|
||||||
|
COALESCE(SUM(a.total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(a.estimated_cost), 0) as cost,
|
||||||
|
COUNT(*) as requests
|
||||||
|
FROM ai_usage_log a
|
||||||
|
WHERE 1=1 $tenantCondition AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE(a.created_at)
|
||||||
|
ORDER BY date ASC";
|
||||||
|
|
||||||
|
$stmt4 = $db->prepare($sql4);
|
||||||
|
$stmt4->execute($params);
|
||||||
|
$dailyTrend = $stmt4->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'period' => $period,
|
||||||
|
'totals' => [
|
||||||
|
'requests' => (int) $totals['total_requests'],
|
||||||
|
'prompt_tokens' => (int) $totals['total_prompt_tokens'],
|
||||||
|
'completion_tokens' => (int) $totals['total_completion_tokens'],
|
||||||
|
'total_tokens' => (int) $totals['total_tokens'],
|
||||||
|
'estimated_cost_usd' => round((float) $totals['total_cost'], 4),
|
||||||
|
],
|
||||||
|
'by_action' => $breakdown,
|
||||||
|
'by_model' => $modelBreakdown,
|
||||||
|
'daily_trend' => $dailyTrend,
|
||||||
|
]);
|
||||||
55
app/modules_app/assignments/create.php
Normal file
55
app/modules_app/assignments/create.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Create User-Company Assignment
|
||||||
|
* POST /v1/assignments/create
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
// Only Admin/Super Admin
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
|
||||||
|
$data = input();
|
||||||
|
$userId = $data['user_id'] ?? null;
|
||||||
|
$companyId = $data['company_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId || !$companyId) {
|
||||||
|
json_error('userId and companyId are required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user belongs to the same tenant (if not super_admin)
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
$stmt = $db->prepare("SELECT tenant_id FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$userTenant = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($userTenant !== $decoded['tenant_id']) {
|
||||||
|
json_error('User does not belong to your office', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO user_company_assignments (id, user_id, company_id, is_active, created_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE is_active = 1
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
Database::generateUuid(),
|
||||||
|
$userId,
|
||||||
|
$companyId,
|
||||||
|
date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
json_success(null, 'تم تخصيص المستخدم للشركة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'assignments/create', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.');
|
||||||
|
}
|
||||||
41
app/modules_app/assignments/index.php
Normal file
41
app/modules_app/assignments/index.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* List Assignments for a Company
|
||||||
|
* GET /v1/assignments?company_id=...
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$companyId = input('company_id');
|
||||||
|
|
||||||
|
if (!$companyId) {
|
||||||
|
json_error('company_id is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT a.id, a.user_id, a.is_active, u.name, u.email, u.role
|
||||||
|
FROM user_company_assignments a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.company_id = ? AND a.is_active = 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
$assignments = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($assignments as &$a) {
|
||||||
|
$a['name'] = Encryption::decrypt($a['name']) ?: $a['name'];
|
||||||
|
$a['email'] = Encryption::decrypt($a['email']) ?: $a['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($assignments);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'assignments/index');
|
||||||
|
}
|
||||||
121
app/modules_app/audit/index.php
Normal file
121
app/modules_app/audit/index.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Audit Log / Activity History
|
||||||
|
* GET /v1/audit-log
|
||||||
|
* Returns paginated activity history
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = min(50, max(10, (int)($_GET['limit'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
$entityType = $_GET['entity_type'] ?? null;
|
||||||
|
$action = $_GET['action'] ?? null;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'a.tenant_id = ?';
|
||||||
|
$params[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entityType) {
|
||||||
|
$where[] = 'a.entity_type = ?';
|
||||||
|
$params[] = $entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action) {
|
||||||
|
$where[] = 'a.action LIKE ?';
|
||||||
|
$params[] = "%$action%";
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Total count
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM audit_logs a $whereClause");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Fetch logs
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT a.*, u.name as user_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Format logs
|
||||||
|
foreach ($logs as &$log) {
|
||||||
|
// Decrypt user name if encrypted
|
||||||
|
if (!empty($log['user_name'])) {
|
||||||
|
$dec = \App\Core\Encryption::decrypt($log['user_name']);
|
||||||
|
$log['user_name'] = ($dec !== false && $dec !== null) ? $dec : $log['user_name'];
|
||||||
|
}
|
||||||
|
$log['old_values'] = json_decode($log['old_data'] ?? '{}', true);
|
||||||
|
$log['details'] = json_decode($log['new_data'] ?? '{}', true);
|
||||||
|
unset($log['old_data'], $log['new_data'], $log['user_agent'], $log['ip_address']);
|
||||||
|
|
||||||
|
// Generate human-readable summary
|
||||||
|
$a = $log['action'] ?? '';
|
||||||
|
if (str_starts_with($a, 'invoice.')) {
|
||||||
|
$log['summary'] = match($a) {
|
||||||
|
'invoice.approved' => 'تم اعتماد فاتورة',
|
||||||
|
'invoice.updated' => 'تم تعديل فاتورة',
|
||||||
|
'invoice.bulk_approved' => 'اعتماد جماعي',
|
||||||
|
'invoice.uploaded' => 'تم رفع فاتورة',
|
||||||
|
'invoice.extracted' => 'تم استخراج بيانات فاتورة',
|
||||||
|
default => $a,
|
||||||
|
};
|
||||||
|
} elseif (str_starts_with($a, 'user.')) {
|
||||||
|
$log['summary'] = match($a) {
|
||||||
|
'user.created' => 'تم إنشاء مستخدم جديد',
|
||||||
|
'user.updated' => 'تم تعديل بيانات مستخدم',
|
||||||
|
'user.deleted' => 'تم حذف مستخدم',
|
||||||
|
'user.login' => 'تسجيل دخول',
|
||||||
|
default => $a,
|
||||||
|
};
|
||||||
|
} elseif (str_starts_with($a, 'company.')) {
|
||||||
|
$log['summary'] = match($a) {
|
||||||
|
'company.created' => 'تم إنشاء شركة جديدة',
|
||||||
|
'company.updated' => 'تم تعديل بيانات شركة',
|
||||||
|
default => $a,
|
||||||
|
};
|
||||||
|
} elseif (str_starts_with($a, 'payment.')) {
|
||||||
|
$log['summary'] = match($a) {
|
||||||
|
'payment.created' => 'تم إنشاء طلب دفع',
|
||||||
|
'payment.uploaded' => 'تم رفع وصل دفع',
|
||||||
|
'payment.approved' => 'تم اعتماد دفعة',
|
||||||
|
default => $a,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
$log['summary'] = $a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($log);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'logs' => $logs,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => $total > 0 ? (int)ceil($total / $limit) : 1,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Audit log error: " . $e->getMessage());
|
||||||
|
safe_error($e, 'audit/index', 'خطأ في جلب سجل النشاط.');
|
||||||
|
}
|
||||||
@@ -28,42 +28,156 @@ if ($errors) {
|
|||||||
$email = $data['email'];
|
$email = $data['email'];
|
||||||
$password = $data['password'];
|
$password = $data['password'];
|
||||||
|
|
||||||
// 2. DB Check
|
// 2. DB Check (Using hash for lookup since email is encrypted)
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
|
$emailHash = hash('sha256', strtolower($email));
|
||||||
$stmt->execute([$email]);
|
$stmt = $db->prepare("SELECT * FROM users WHERE email_hash = ? LIMIT 1");
|
||||||
|
$stmt->execute([$emailHash]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
json_error('بيانات الدخول غير صحيحة', 401);
|
json_error('بيانات الدخول غير صحيحة', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Issue Token
|
$deviceId = $data['device_id'] ?? null;
|
||||||
|
$isReviewer = (strtolower($email) === 'reviewer@musadaq.jo');
|
||||||
|
|
||||||
|
if ($deviceId && !$isReviewer) {
|
||||||
|
// Generate and send WhatsApp OTP
|
||||||
|
$phone = $user['phone'] ? (\App\Core\Encryption::decrypt($user['phone']) ?: $user['phone']) : null;
|
||||||
|
if (empty($phone)) {
|
||||||
|
json_error('رقم الهاتف غير مسجل لهذا المستخدم. يرجى التواصل مع المسؤول.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone = preg_replace('/[^0-9+]/', '', $phone);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
$otpHash = password_hash($otp, PASSWORD_DEFAULT);
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
|
$cacheDir = STORAGE_PATH . '/cache/otp';
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$otpData = [
|
||||||
|
'hash' => $otpHash,
|
||||||
|
'user_id' => $user['id'],
|
||||||
|
'attempts' => 0,
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'expires_at' => time() + 300,
|
||||||
|
'created_at' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w');
|
||||||
|
if ($fp) {
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
fwrite($fp, json_encode($otpData));
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
$whatsappService = new \App\Services\WhatsAppProxyService();
|
||||||
|
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
|
||||||
|
$result = $whatsappService->sendMessage($phone, $message);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
|
||||||
|
json_error('عذراً، فشل في إرسال رمز التحقق. يرجى المحاولة مرة أخرى.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env('APP_DEBUG', 'false') === 'true') {
|
||||||
|
error_log("DEV OTP for {$phone}: {$otp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'otp_required' => true,
|
||||||
|
'phone' => $phone,
|
||||||
|
], 'تم إرسال رمز التحقق إلى رقم هاتفك المسجل عبر واتساب');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle device registration if provided (for mobile app login)
|
||||||
|
$deviceName = $data['device_name'] ?? 'Web Browser';
|
||||||
|
$deviceSecret = null;
|
||||||
|
|
||||||
|
if ($deviceId) {
|
||||||
|
$deviceSecret = hash('sha256', $user['id'] . $deviceId . bin2hex(random_bytes(16)));
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, device_secret, is_trusted, last_seen_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, ?, TRUE, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
device_name = VALUES(device_name),
|
||||||
|
platform = VALUES(platform),
|
||||||
|
app_version = VALUES(app_version),
|
||||||
|
device_secret = VALUES(device_secret),
|
||||||
|
is_trusted = TRUE,
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$user['id'],
|
||||||
|
$deviceId,
|
||||||
|
$deviceName,
|
||||||
|
$data['platform'] ?? 'web',
|
||||||
|
$data['app_version'] ?? '1.0.0',
|
||||||
|
password_hash($deviceSecret, PASSWORD_DEFAULT),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Issue Token
|
||||||
$secret = env('JWT_SECRET');
|
$secret = env('JWT_SECRET');
|
||||||
if (!$secret || strlen($secret) < 32) {
|
if (!$secret || strlen($secret) < 32) {
|
||||||
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||||||
json_error('Server configuration error', 500);
|
json_error('Server configuration error', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Longer expiry for mobile (30 days), short for web (15 mins)
|
||||||
|
$expiry = $deviceId ? (30 * 24 * 3600) : (15 * 60);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'role' => $user['role'],
|
'tenant_id' => $user['tenant_id'],
|
||||||
'exp' => time() + (15 * 60) // 15 minutes
|
'role' => $user['role'],
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'source' => $deviceId ? 'mobile' : 'web',
|
||||||
|
'exp' => time() + $expiry
|
||||||
];
|
];
|
||||||
|
|
||||||
$token = JWT::encode($payload, $secret);
|
$token = JWT::encode($payload, $secret);
|
||||||
|
|
||||||
// 4. Update Refresh Token (Hashed before storage for security)
|
// 5. Update Refresh Token (Hashed before storage for security)
|
||||||
$refreshToken = bin2hex(random_bytes(32));
|
$refreshToken = bin2hex(random_bytes(32));
|
||||||
$refreshTokenHash = hash('sha256', $refreshToken);
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
|
||||||
$stmt->execute([$refreshTokenHash, $user['id']]);
|
$stmt->execute([$refreshTokenHash, $user['id']]);
|
||||||
|
|
||||||
|
// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
|
||||||
|
if (!$deviceId) {
|
||||||
|
setcookie('refresh_token', $refreshToken, [
|
||||||
|
'expires' => time() + (7 * 24 * 60 * 60), // 7 days
|
||||||
|
'path' => '/api/v1/auth/refresh',
|
||||||
|
'secure' => true,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'access_token' => $token,
|
'access_token' => $token,
|
||||||
'refresh_token' => $refreshToken,
|
'refresh_token' => $refreshToken,
|
||||||
|
'device_secret' => $deviceSecret,
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user['id'],
|
'id' => $user['id'],
|
||||||
'name' => $user['name'],
|
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
|
||||||
'email' => $user['email']
|
'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
|
||||||
|
'role' => $user['role'],
|
||||||
|
'tenant_id' => $user['tenant_id']
|
||||||
]
|
]
|
||||||
], 'تم تسجيل الدخول بنجاح');
|
], 'تم تسجيل الدخول بنجاح');
|
||||||
|
|||||||
121
app/modules_app/auth/mobile_request_otp.php
Normal file
121
app/modules_app/auth/mobile_request_otp.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Mobile OTP Request Endpoint
|
||||||
|
* POST /v1/auth/mobile/request-otp
|
||||||
|
*
|
||||||
|
* Sends an OTP to the user's registered phone number.
|
||||||
|
* The phone must already be registered by an admin in the web dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\RateLimitMiddleware;
|
||||||
|
|
||||||
|
// Rate limit: 3 OTP requests per minute per IP
|
||||||
|
RateLimitMiddleware::check(3, 60);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
// 1. Validate
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'phone' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('رقم الهاتف مطلوب', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
|
// 2. Find user by phone hash OR plain phone (Support both schemas)
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// First, try to find by phone_hash. If it fails, we'll catch it.
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone_hash = ? LIMIT 1");
|
||||||
|
$stmt->execute([$phoneHash]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
try {
|
||||||
|
// Fallback to searching by plain phone if phone_hash column doesn't exist
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone = ? LIMIT 1");
|
||||||
|
$stmt->execute([$phone]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
} catch (\PDOException $fallbackException) {
|
||||||
|
json_error('حدث خطأ في قاعدة البيانات: ' . $fallbackException->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
// Don't reveal if phone exists — generic message
|
||||||
|
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user['is_active']) {
|
||||||
|
json_error('الحساب معطّل. تواصل مع المسؤول.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate OTP (6 digits)
|
||||||
|
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
$otpHash = password_hash($otp, PASSWORD_DEFAULT);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
|
||||||
|
|
||||||
|
// 4. Store OTP in database (or Redis if available)
|
||||||
|
$cacheDir = STORAGE_PATH . '/cache/otp';
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$otpData = [
|
||||||
|
'hash' => $otpHash,
|
||||||
|
'user_id' => $user['id'],
|
||||||
|
'attempts' => 0,
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'expires_at' => time() + 300,
|
||||||
|
'created_at' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w');
|
||||||
|
if ($fp) {
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
fwrite($fp, json_encode($otpData));
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Send OTP via WhatsApp Proxy
|
||||||
|
$whatsappService = new \App\Services\WhatsAppProxyService();
|
||||||
|
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
|
||||||
|
$result = $whatsappService->sendMessage($phone, $message);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
|
||||||
|
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500, ['whatsapp_debug' => $result]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for development (REMOVE IN PRODUCTION!)
|
||||||
|
if (env('APP_DEBUG', 'false') === 'true') {
|
||||||
|
error_log("DEV OTP for {$phone}: {$otp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'auth/mobile_request_otp');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
182
app/modules_app/auth/mobile_verify_otp.php
Normal file
182
app/modules_app/auth/mobile_verify_otp.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Mobile OTP Verify Endpoint
|
||||||
|
* POST /v1/auth/mobile/verify-otp
|
||||||
|
*
|
||||||
|
* Verifies OTP, registers device, and returns JWT + device secret for HMAC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\JWT;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\RateLimitMiddleware;
|
||||||
|
|
||||||
|
// Rate limit: 10 verify attempts per minute per IP
|
||||||
|
RateLimitMiddleware::check(10, 60);
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
// 1. Validate
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'phone' => 'required',
|
||||||
|
'otp' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('رقم الهاتف ورمز التحقق مطلوبان', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
||||||
|
$phone = ltrim($phone, '+');
|
||||||
|
if (str_starts_with($phone, '07')) {
|
||||||
|
$phone = '962' . substr($phone, 1);
|
||||||
|
} elseif (str_starts_with($phone, '7')) {
|
||||||
|
$phone = '962' . $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
$deviceId = $data['device_id'] ?? '';
|
||||||
|
$deviceName = $data['device_name'] ?? 'Unknown Device';
|
||||||
|
$platform = $data['platform'] ?? 'android';
|
||||||
|
$appVersion = $data['app_version'] ?? '1.0.0';
|
||||||
|
$pushToken = $data['push_token'] ?? null;
|
||||||
|
|
||||||
|
if (empty($deviceId)) {
|
||||||
|
json_error('معرّف الجهاز مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load OTP from cache
|
||||||
|
$cacheFile = STORAGE_PATH . '/cache/otp/otp_' . $phoneHash . '.json';
|
||||||
|
if (!file_exists($cacheFile)) {
|
||||||
|
json_error('رمز التحقق غير صالح أو منتهي الصلاحية', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fp = fopen($cacheFile, 'r+');
|
||||||
|
if (!$fp) {
|
||||||
|
json_error('خطأ في النظام', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
$content = stream_get_contents($fp);
|
||||||
|
$otpData = json_decode($content, true);
|
||||||
|
|
||||||
|
if (!$otpData || $otpData['expires_at'] < time()) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
@unlink($cacheFile);
|
||||||
|
json_error('رمز التحقق منتهي الصلاحية. اطلب رمزاً جديداً.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check attempts
|
||||||
|
if ($otpData['attempts'] >= $otpData['max_attempts']) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
@unlink($cacheFile);
|
||||||
|
json_error('تجاوزت عدد المحاولات المسموحة. اطلب رمزاً جديداً.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify OTP
|
||||||
|
if (!password_verify($data['otp'], $otpData['hash'])) {
|
||||||
|
$otpData['attempts']++;
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($otpData));
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
$remaining = $otpData['max_attempts'] - $otpData['attempts'];
|
||||||
|
json_error("رمز التحقق غير صحيح. المحاولات المتبقية: {$remaining}", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP is valid — clean up
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
@unlink($cacheFile);
|
||||||
|
|
||||||
|
// 3. Fetch user
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$userId = $otpData['user_id'];
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id, name, email, role, is_active FROM users WHERE id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || !$user['is_active']) {
|
||||||
|
json_error('الحساب غير موجود أو معطّل', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Generate device secret for HMAC
|
||||||
|
$deviceSecret = hash('sha256', $userId . $deviceId . bin2hex(random_bytes(16)));
|
||||||
|
|
||||||
|
// 5. Register/Update device
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, push_token, device_secret, is_trusted, last_seen_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, TRUE, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
device_name = VALUES(device_name),
|
||||||
|
platform = VALUES(platform),
|
||||||
|
app_version = VALUES(app_version),
|
||||||
|
push_token = VALUES(push_token),
|
||||||
|
device_secret = VALUES(device_secret),
|
||||||
|
is_trusted = TRUE,
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$deviceId,
|
||||||
|
$deviceName,
|
||||||
|
$platform,
|
||||||
|
$appVersion,
|
||||||
|
$pushToken,
|
||||||
|
password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. Generate JWT (30 days for mobile)
|
||||||
|
$secret = env('JWT_SECRET');
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'user_id' => $user['id'],
|
||||||
|
'tenant_id' => $user['tenant_id'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'source' => 'mobile',
|
||||||
|
'exp' => time() + (30 * 24 * 3600), // 30 days
|
||||||
|
];
|
||||||
|
|
||||||
|
$token = JWT::encode($payload, $secret);
|
||||||
|
|
||||||
|
// 7. Generate refresh token
|
||||||
|
$refreshToken = bin2hex(random_bytes(32));
|
||||||
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
|
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$refreshTokenHash, $userId]);
|
||||||
|
|
||||||
|
// 8. Decrypt name for response
|
||||||
|
$userName = $user['name'];
|
||||||
|
try {
|
||||||
|
$decrypted = \App\Core\Encryption::decrypt($user['name']);
|
||||||
|
if ($decrypted !== false) $userName = $decrypted;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Keep encrypted name
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'access_token' => $token,
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'device_secret' => $deviceSecret, // Client stores this securely for HMAC
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'name' => $userName,
|
||||||
|
'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
|
||||||
|
'role' => $user['role'],
|
||||||
|
'tenant_id' => $user['tenant_id'],
|
||||||
|
],
|
||||||
|
], 'تم التحقق بنجاح. مرحباً بك في مُصادَق!');
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Auth Refresh Endpoint
|
* Refresh Token Endpoint (Secure Cookie Based)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\JWT;
|
use Firebase\JWT\JWT;
|
||||||
|
|
||||||
$data = input();
|
// 1. Get Refresh Token from HttpOnly Cookie
|
||||||
$refreshToken = $data['refresh_token'] ?? null;
|
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
|
||||||
if (!$refreshToken) {
|
if (!$refreshToken) {
|
||||||
json_error('Refresh token is required', 400);
|
json_error('Refresh token is required', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$refreshTokenHash = hash('sha256', $refreshToken);
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
|
|
||||||
|
// 2. Verify in DB
|
||||||
|
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1");
|
||||||
$stmt->execute([$refreshTokenHash]);
|
$stmt->execute([$refreshTokenHash]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
@@ -23,25 +25,21 @@ if (!$user) {
|
|||||||
json_error('Invalid refresh token', 401);
|
json_error('Invalid refresh token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$secret = env('JWT_SECRET');
|
// 3. Generate New Access Token
|
||||||
if (!$secret || strlen($secret) < 32) {
|
$secret = $_ENV['JWT_SECRET'] ?? null;
|
||||||
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
if (!$secret) {
|
||||||
json_error('Server configuration error', 500);
|
json_error('Server configuration error', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'role' => $user['role'],
|
'tenant_id' => $user['tenant_id'], // Now including tenant_id
|
||||||
'exp' => time() + (15 * 60)
|
'role' => $user['role'],
|
||||||
|
'exp' => time() + (15 * 60) // 15 minutes
|
||||||
];
|
];
|
||||||
|
|
||||||
$newToken = JWT::encode($payload, $secret);
|
$token = JWT::encode($payload, $secret, 'HS256');
|
||||||
$newRefreshToken = bin2hex(random_bytes(32));
|
|
||||||
$newRefreshTokenHash = hash('sha256', $newRefreshToken);
|
|
||||||
|
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
|
||||||
$stmt->execute([$newRefreshTokenHash, $user['id']]);
|
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'access_token' => $newToken,
|
'access_token' => $token
|
||||||
'refresh_token' => $newRefreshToken
|
]);
|
||||||
], 'تم تجديد الجلسة بنجاح');
|
|
||||||
|
|||||||
60
app/modules_app/auth/register_device.php
Normal file
60
app/modules_app/auth/register_device.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Register/Update Device Endpoint
|
||||||
|
* POST /v1/auth/mobile/register-device
|
||||||
|
*
|
||||||
|
* Updates push token and device info for an already-authenticated device.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$deviceId = $decoded['device_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$deviceId) {
|
||||||
|
json_error('هذا الـ endpoint مخصص لتطبيق الهاتف فقط', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$updateFields = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (isset($data['push_token'])) {
|
||||||
|
$updateFields[] = 'push_token = ?';
|
||||||
|
$params[] = $data['push_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['app_version'])) {
|
||||||
|
$updateFields[] = 'app_version = ?';
|
||||||
|
$params[] = $data['app_version'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['device_name'])) {
|
||||||
|
$updateFields[] = 'device_name = ?';
|
||||||
|
$params[] = $data['device_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update last_seen
|
||||||
|
$updateFields[] = 'last_seen_at = NOW()';
|
||||||
|
|
||||||
|
if (empty($updateFields)) {
|
||||||
|
json_success(null, 'لا يوجد بيانات للتحديث');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "UPDATE user_devices SET " . implode(', ', $updateFields) . " WHERE user_id = ? AND device_fingerprint = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
$params[] = $deviceId;
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
json_success(null, 'تم تحديث بيانات الجهاز');
|
||||||
83
app/modules_app/batches/create.php
Normal file
83
app/modules_app/batches/create.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Create Batch Endpoint
|
||||||
|
* POST /v1/batches/create
|
||||||
|
*
|
||||||
|
* Creates a new invoice batch for the mobile scanner.
|
||||||
|
* Returns batch_id that the mobile app uses to upload images.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
// 1. Validate
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'company_id' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('رقم الشركة مطلوب', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyId = $data['company_id'];
|
||||||
|
$source = $data['source'] ?? 'mobile_scan';
|
||||||
|
$expectedImages = (int)($data['expected_images'] ?? 0);
|
||||||
|
|
||||||
|
// 2. Permission check
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الشركة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tenant match if not super_admin
|
||||||
|
if ($decoded['role'] !== 'super_admin' && $company['tenant_id'] !== $tenantId) {
|
||||||
|
json_error('الوصول مرفوض لهذه الشركة', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the actual tenant of the company
|
||||||
|
$targetTenantId = $company['tenant_id'];
|
||||||
|
|
||||||
|
// 3. Check quota (preview — don't increment yet)
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
try {
|
||||||
|
QuotaMiddleware::checkInvoiceQuota($targetTenantId);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Generate batch ID
|
||||||
|
$batchId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
|
||||||
|
// 5. Create batch record
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $expectedImages, $source]);
|
||||||
|
|
||||||
|
// 6. Create upload directory
|
||||||
|
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'upload_url' => 'v1/batches/upload-image',
|
||||||
|
], 'تم إنشاء الدفعة بنجاح. ابدأ برفع الصور.');
|
||||||
142
app/modules_app/batches/finalize.php
Normal file
142
app/modules_app/batches/finalize.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Finalize Batch Endpoint
|
||||||
|
* POST /v1/batches/finalize
|
||||||
|
*
|
||||||
|
* Marks a batch as ready for processing.
|
||||||
|
* Sends instant response to mobile app, then processes in background via fastcgi_finish_request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Services\InvoiceProcessor;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$batchId = $data['batch_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$batchId) {
|
||||||
|
json_error('معرّف الدفعة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 1. Verify batch
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, tenant_id, status, total_images
|
||||||
|
FROM invoice_batches
|
||||||
|
WHERE id = ? AND uploaded_by = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId, $userId]);
|
||||||
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
|
json_error('الدفعة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch['status'] !== 'uploading') {
|
||||||
|
json_error('تم إنهاء هذه الدفعة مسبقاً', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch['total_images'] == 0) {
|
||||||
|
json_error('لا يمكن إنهاء دفعة فارغة', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mark as processing
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE invoice_batches
|
||||||
|
SET status = 'processing', updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
|
||||||
|
// 3. Send response IMMEDIATELY to mobile app
|
||||||
|
// We manually build the response instead of using json_success() because it calls exit()
|
||||||
|
$responsePayload = json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'status' => 'processing',
|
||||||
|
'total_images' => $batch['total_images']
|
||||||
|
],
|
||||||
|
'message' => 'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية',
|
||||||
|
'timestamp' => date('c')
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Content-Length: ' . strlen($responsePayload));
|
||||||
|
http_response_code(200);
|
||||||
|
|
||||||
|
// Flush ALL output buffers to send response to client NOW
|
||||||
|
echo $responsePayload;
|
||||||
|
|
||||||
|
// Flush PHP output buffers
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
// Log the API call for app.log (mimicking json_response behavior)
|
||||||
|
$logEntry = sprintf(
|
||||||
|
"API %s %s | 200 | OK | %s",
|
||||||
|
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||||
|
$_SERVER['REQUEST_URI'] ?? '',
|
||||||
|
'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية'
|
||||||
|
);
|
||||||
|
error_log($logEntry);
|
||||||
|
@file_put_contents(
|
||||||
|
STORAGE_PATH . '/logs/app.log',
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Tell PHP-FPM: "The client response is done. But keep this PHP process alive."
|
||||||
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
|
fastcgi_finish_request();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Now process in the background (client has already received the response)
|
||||||
|
ignore_user_abort(true);
|
||||||
|
set_time_limit(300); // 5 minutes max
|
||||||
|
|
||||||
|
$bgLog = function(string $msg) {
|
||||||
|
@file_put_contents(
|
||||||
|
STORAGE_PATH . '/logs/worker.log',
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] [finalize-bg] " . $msg . "\n",
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$bgLog("Background processing started for batch: $batchId");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$queueStmt = $db->prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending' ORDER BY created_at ASC");
|
||||||
|
$queueStmt->execute([$batchId]);
|
||||||
|
$items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$bgLog("Found " . count($items) . " pending item(s) for batch $batchId");
|
||||||
|
|
||||||
|
foreach ($items as $queueId) {
|
||||||
|
$bgLog("Processing queue item: $queueId");
|
||||||
|
try {
|
||||||
|
$success = InvoiceProcessor::processQueueItem((int)$queueId);
|
||||||
|
$bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED"));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$bgLog("Queue item $queueId EXCEPTION: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bgLog("Background processing finished for batch: $batchId");
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$bgLog("FATAL ERROR in background processing: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
38
app/modules_app/batches/process_worker.php
Normal file
38
app/modules_app/batches/process_worker.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Background Worker Trigger (HTTP)
|
||||||
|
* POST /api/v1/batches/process-worker
|
||||||
|
*
|
||||||
|
* This endpoint is triggered by finalize.php to start processing in the background.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../bootstrap/init.php';
|
||||||
|
|
||||||
|
use App\Services\InvoiceProcessor;
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
// 1. Ignore user abort and set no time limit
|
||||||
|
ignore_user_abort(true);
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
// 2. Get batch ID
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$batchId = $data['batch_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$batchId) {
|
||||||
|
exit('No batch ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process all pending items for this batch
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending'");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
InvoiceProcessor::processQueueItem((int)$item['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done";
|
||||||
54
app/modules_app/batches/status.php
Normal file
54
app/modules_app/batches/status.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Batch Status Endpoint
|
||||||
|
* GET /v1/batches/status
|
||||||
|
*
|
||||||
|
* Returns the processing status of a batch and its items.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
$data = Security::sanitize($_GET);
|
||||||
|
$batchId = $data['batch_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$batchId) {
|
||||||
|
json_error('معرّف الدفعة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 1. Get batch info
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, tenant_id, status, total_images, processed_images, failed_images, created_at, completed_at
|
||||||
|
FROM invoice_batches
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
|
json_error('الدفعة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get items
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, invoice_id, image_order, status, error_message, created_at, processed_at
|
||||||
|
FROM invoice_processing_queue
|
||||||
|
WHERE batch_id = ?
|
||||||
|
ORDER BY image_order ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'batch' => $batch,
|
||||||
|
'items' => $items
|
||||||
|
], 'تم جلب حالة الدفعة');
|
||||||
100
app/modules_app/batches/upload_image.php
Normal file
100
app/modules_app/batches/upload_image.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Upload Image to Batch
|
||||||
|
* POST /v1/batches/upload-image
|
||||||
|
*
|
||||||
|
* Uploads a single image to an existing batch.
|
||||||
|
* Supports multipart/form-data with 'image' file and 'batch_id'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
// 1. Validate request
|
||||||
|
$batchId = $_POST['batch_id'] ?? null;
|
||||||
|
$imageOrder = (int)($_POST['image_order'] ?? 0);
|
||||||
|
|
||||||
|
if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$uploadError = $_FILES['image']['error'] ?? 'No file';
|
||||||
|
json_error("معرّف الدفعة وصورة الفاتورة مطلوبان (كود: {$uploadError})", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify batch belongs to this user and tenant
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, tenant_id, company_id, status, total_images
|
||||||
|
FROM invoice_batches
|
||||||
|
WHERE id = ? AND uploaded_by = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId, $userId]);
|
||||||
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
|
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override tenantId with the actual batch's tenantId
|
||||||
|
$tenantId = $batch['tenant_id'];
|
||||||
|
|
||||||
|
if ($batch['status'] !== 'uploading') {
|
||||||
|
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate file type
|
||||||
|
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
|
||||||
|
$mimeType = $_FILES['image']['type'];
|
||||||
|
if (!in_array($mimeType, $allowedTypes)) {
|
||||||
|
json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Validate file size (max 10MB)
|
||||||
|
$maxSize = 10 * 1024 * 1024;
|
||||||
|
if ($_FILES['image']['size'] > $maxSize) {
|
||||||
|
json_error('حجم الصورة أكبر من 10 ميغابايت', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Save file
|
||||||
|
$companyId = $batch['company_id'];
|
||||||
|
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$fileName = sprintf('img_%03d_%s.%s', $imageOrder, bin2hex(random_bytes(4)), $extension);
|
||||||
|
$targetPath = $uploadDir . '/' . $fileName;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($_FILES['image']['tmp_name'], $targetPath)) {
|
||||||
|
json_error('فشل في حفظ الصورة', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Add to processing queue
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoice_processing_queue (batch_id, tenant_id, company_id, image_path, image_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending')
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId, $tenantId, $companyId, $targetPath, $imageOrder]);
|
||||||
|
|
||||||
|
// 7. Update batch image count
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE invoice_batches
|
||||||
|
SET total_images = total_images + 1, updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
|
||||||
|
// Count uploaded so far
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoice_processing_queue WHERE batch_id = ?");
|
||||||
|
$stmt->execute([$batchId]);
|
||||||
|
$uploadedCount = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'uploaded' => $uploadedCount,
|
||||||
|
'file_name' => $fileName,
|
||||||
|
], "تم رفع الصورة بنجاح ({$uploadedCount} صور في الدفعة)");
|
||||||
88
app/modules_app/chatbot/ask.php
Normal file
88
app/modules_app/chatbot/ask.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Accounting Chatbot — "اسأل مُصادَق"
|
||||||
|
* POST /v1/chatbot/ask
|
||||||
|
* Body: { "question": "كم ضريبة المبيعات على الخدمات الرقمية؟" }
|
||||||
|
*
|
||||||
|
* AI-powered chatbot that answers accounting & tax questions
|
||||||
|
* with context from the user's own data when relevant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$question = trim($data['question'] ?? '');
|
||||||
|
|
||||||
|
if (empty($question) || mb_strlen($question) < 3) {
|
||||||
|
json_error('يرجى كتابة سؤالك (3 أحرف على الأقل)', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($question) > 500) {
|
||||||
|
json_error('السؤال طويل جداً (الحد 500 حرف)', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Gather user context (last month stats)
|
||||||
|
$contextStmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_revenue,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
");
|
||||||
|
$contextStmt->execute([$tenantId]);
|
||||||
|
$context = $contextStmt->fetch();
|
||||||
|
|
||||||
|
$companyStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$companyStmt->execute([$tenantId]);
|
||||||
|
$companyCount = (int)$companyStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 2. Build AI prompt
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
أنت "مُصادَق" — مساعد محاسبي ذكي متخصص في المحاسبة والضرائب الأردنية.
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
1. أجب بالعربية دائماً وبشكل مختصر ومفيد
|
||||||
|
2. إذا كان السؤال عن ضرائب أردنية، استخدم نسب ضريبة المبيعات الأردنية (16% عامة، 4% و8% مخفضة، 0% معفاة)
|
||||||
|
3. إذا كان السؤال غير محاسبي، قل "أنا متخصص بالمحاسبة والضرائب فقط"
|
||||||
|
4. لا تعطِ نصائح قانونية نهائية — انصح بمراجعة محاسب قانوني للحالات المعقدة
|
||||||
|
5. إذا كان السؤال يتعلق ببيانات المستخدم، استخدم السياق المتاح
|
||||||
|
|
||||||
|
سياق المستخدم (آخر 30 يوم):
|
||||||
|
- عدد الفواتير: {$context['total_invoices']}
|
||||||
|
- إجمالي الإيرادات: {$context['total_revenue']} دينار
|
||||||
|
- إجمالي الضريبة: {$context['total_tax']} دينار
|
||||||
|
- عدد الشركات: {$companyCount}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$aiResponse = AI::chat($systemPrompt, $question, $tenantId);
|
||||||
|
|
||||||
|
if (!$aiResponse) {
|
||||||
|
json_error('عذراً، لم أتمكن من معالجة سؤالك. حاول مرة أخرى.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Log the conversation
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO chatbot_history (id, user_id, tenant_id, question, answer, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, NOW())
|
||||||
|
")->execute([$userId, $tenantId, $question, $aiResponse]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'answer' => $aiResponse,
|
||||||
|
'question' => $question,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
], 'إجابة مُصادَق');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'chatbot/ask', 'حدث خطأ في المساعد الذكي.');
|
||||||
|
}
|
||||||
29
app/modules_app/chatbot/history.php
Normal file
29
app/modules_app/chatbot/history.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Chatbot History
|
||||||
|
* GET /v1/chatbot/history
|
||||||
|
* Returns user's recent chatbot conversations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$pagination = paginate_params(20, 50);
|
||||||
|
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM chatbot_history WHERE user_id = ?");
|
||||||
|
$countStmt->execute([$decoded['user_id']]);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, question, answer, created_at
|
||||||
|
FROM chatbot_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute([$decoded['user_id']]);
|
||||||
|
|
||||||
|
json_paginated($stmt->fetchAll(), $total, $pagination);
|
||||||
65
app/modules_app/companies/connect_jofotara.php
Normal file
65
app/modules_app/companies/connect_jofotara.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Link Company to JoFotara API
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\JoFotara;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
if (!in_array($decoded['role'], ['super_admin', 'admin'])) {
|
||||||
|
json_error('Unauthorized to modify JoFotara settings', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$companyId = $data['id'] ?? null;
|
||||||
|
$clientId = $data['client_id'] ?? null;
|
||||||
|
$secretKey = $data['secret_key'] ?? null;
|
||||||
|
$sequence = $data['income_source_sequence'] ?? null;
|
||||||
|
|
||||||
|
if (!$companyId || !$clientId || !$secretKey) {
|
||||||
|
json_error('Company ID, Client ID, and Secret Key are required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Validate Company Ownership
|
||||||
|
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ?");
|
||||||
|
$stmt->execute([$companyId, $tenantId]);
|
||||||
|
if (!$stmt->fetch()) json_error('Access denied', 403);
|
||||||
|
|
||||||
|
// 3. Test Connection (Optional but recommended)
|
||||||
|
$jofotara = new JoFotara();
|
||||||
|
// Here you would typically call a health check endpoint if JoFotara provides one,
|
||||||
|
// or just assume the credentials are correct for now.
|
||||||
|
|
||||||
|
// 4. Update Company with Encrypted Credentials
|
||||||
|
$stmtUpdate = $db->prepare("
|
||||||
|
UPDATE companies
|
||||||
|
SET
|
||||||
|
jofotara_client_id_encrypted = ?,
|
||||||
|
jofotara_secret_key_encrypted = ?,
|
||||||
|
jofotara_income_source_sequence = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmtUpdate->execute([
|
||||||
|
Encryption::encrypt($clientId),
|
||||||
|
Encryption::encrypt($secretKey),
|
||||||
|
$sequence,
|
||||||
|
$companyId
|
||||||
|
]);
|
||||||
|
|
||||||
|
json_success(null, 'تم ربط الشركة بنظام جوفوترة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("JoFotara Connection Error: " . $e->getMessage());
|
||||||
|
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
|
||||||
|
}
|
||||||
94
app/modules_app/companies/create.php
Normal file
94
app/modules_app/companies/create.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Create Company Endpoint (Synchronized Schema)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
|
||||||
|
$data = input();
|
||||||
|
|
||||||
|
// 1. Validation
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'name' => 'required',
|
||||||
|
'tax_identification_number' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('Validation Failed', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 2. Encrypt sensitive fields
|
||||||
|
$encryptedName = Encryption::encrypt($data['name']);
|
||||||
|
$encryptedNameEn = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null;
|
||||||
|
|
||||||
|
// Encrypt JoFotara keys if provided
|
||||||
|
$jofotaraClientId = !empty($data['jofotara_client_id']) ? Encryption::encrypt($data['jofotara_client_id']) : null;
|
||||||
|
$jofotaraSecretKey = !empty($data['jofotara_secret_key']) ? Encryption::encrypt($data['jofotara_secret_key']) : null;
|
||||||
|
|
||||||
|
// 3. Save to Database
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO companies (
|
||||||
|
id, tenant_id, name, name_en, tax_identification_number, commercial_registration_number,
|
||||||
|
city, address, contact_email, contact_phone,
|
||||||
|
jofotara_client_id_encrypted, jofotara_secret_key_encrypted,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Determine tenant_id: Super Admin chooses, Admin uses own
|
||||||
|
$tenantId = null;
|
||||||
|
if ($decoded['role'] === 'super_admin') {
|
||||||
|
if (empty($data['tenant_id'])) {
|
||||||
|
json_error('يجب اختيار المكتب المحاسبي', 422);
|
||||||
|
}
|
||||||
|
$tenantId = $data['tenant_id'];
|
||||||
|
} else {
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUOTA CHECK ---
|
||||||
|
\App\Middleware\QuotaMiddleware::checkCompanyQuota($tenantId);
|
||||||
|
// -------------------
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
\App\Core\Database::generateUuid(),
|
||||||
|
$tenantId,
|
||||||
|
$encryptedName,
|
||||||
|
$encryptedNameEn,
|
||||||
|
$data['tax_identification_number'],
|
||||||
|
$data['commercial_registration_number'] ?? null,
|
||||||
|
$data['city'] ?? null,
|
||||||
|
$data['address'] ?? null,
|
||||||
|
$data['contact_email'] ?? null,
|
||||||
|
$data['contact_phone'] ?? null,
|
||||||
|
$jofotaraClientId,
|
||||||
|
$jofotaraSecretKey,
|
||||||
|
date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
AuditLogger::log('company.created', 'company', null, null, [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'tin' => $data['tax_identification_number'],
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم إنشاء الشركة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
error_log("[companies/create] Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
|
}
|
||||||
43
app/modules_app/companies/delete.php
Normal file
43
app/modules_app/companies/delete.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Delete Company Endpoint (Soft Delete)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Middleware\CompanyAccessMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$companyId = input('id');
|
||||||
|
if (!$companyId) {
|
||||||
|
json_error('Company ID is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
||||||
|
json_error('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch company to check tenant if admin
|
||||||
|
$stmt = $db->prepare("SELECT tenant_id FROM companies WHERE id = ?");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الشركة غير موجودة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant access (admin can only delete from their tenant)
|
||||||
|
CompanyAccessMiddleware::check($companyId, $decoded);
|
||||||
|
|
||||||
|
// Soft Delete
|
||||||
|
$stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
|
||||||
|
AuditLogger::log('company.deleted', 'company', $companyId, null, null, $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم حذف الشركة بنجاح');
|
||||||
68
app/modules_app/companies/index.php
Normal file
68
app/modules_app/companies/index.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* List Companies Endpoint (Synchronized Schema)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Super Admin sees ALL companies
|
||||||
|
if ($decoded['role'] === 'super_admin') {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT c.*, t.name as tenant_name,
|
||||||
|
(SELECT COUNT(*) FROM invoices WHERE company_id = c.id AND deleted_at IS NULL) as invoices_count,
|
||||||
|
(SELECT SUM(grand_total) FROM invoices WHERE company_id = c.id AND deleted_at IS NULL) as total_amount
|
||||||
|
FROM companies c
|
||||||
|
LEFT JOIN tenants t ON c.tenant_id = t.id
|
||||||
|
WHERE c.deleted_at IS NULL ORDER BY c.created_at DESC
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$companies = $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
// 2. Tenant Users (Admin, Accountant, Employee) see all companies in their tenant
|
||||||
|
else {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT *,
|
||||||
|
(SELECT COUNT(*) FROM invoices WHERE company_id = companies.id AND deleted_at IS NULL) as invoices_count,
|
||||||
|
(SELECT SUM(grand_total) FROM invoices WHERE company_id = companies.id AND deleted_at IS NULL) as total_amount
|
||||||
|
FROM companies
|
||||||
|
WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
|
||||||
|
");
|
||||||
|
$stmt->execute([$decoded['tenant_id']]);
|
||||||
|
$companies = $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Decrypt fields
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = \App\Core\Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($companies as &$company) {
|
||||||
|
$company['name'] = $dec($company['name']);
|
||||||
|
|
||||||
|
if (!empty($company['name_en'])) {
|
||||||
|
$company['name_en'] = $dec($company['name_en']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($company['tenant_name'])) {
|
||||||
|
$company['tenant_name'] = $dec($company['tenant_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact JoFotara secrets
|
||||||
|
$company['jofotara_client_id_encrypted'] = !empty($company['jofotara_client_id_encrypted']);
|
||||||
|
unset($company['jofotara_secret_key_encrypted']);
|
||||||
|
unset($company['certificate_password_encrypted']);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($companies);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'companies/index');
|
||||||
|
}
|
||||||
82
app/modules_app/companies/stats.php
Normal file
82
app/modules_app/companies/stats.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Company Monthly Stats & JoFotara Status
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$companyId = $_GET['company_id'] ?? $_GET['id'] ?? null;
|
||||||
|
if (!$companyId) json_error('Company ID is required', 422);
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Permission Check
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
$stmt = $db->prepare("SELECT id, name, tax_identification_number, is_active,
|
||||||
|
(jofotara_client_id_encrypted IS NOT NULL) as is_jofotara_connected,
|
||||||
|
jofotara_income_source_sequence
|
||||||
|
FROM companies WHERE id = ?");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("SELECT id, name, tax_identification_number, is_active,
|
||||||
|
(jofotara_client_id_encrypted IS NOT NULL) as is_jofotara_connected,
|
||||||
|
jofotara_income_source_sequence
|
||||||
|
FROM companies WHERE id = ? AND tenant_id = ?");
|
||||||
|
$stmt->execute([$companyId, $tenantId]);
|
||||||
|
}
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) json_error('Company not found', 404);
|
||||||
|
|
||||||
|
// Decrypt company name
|
||||||
|
$dec = Encryption::decrypt($company['name']);
|
||||||
|
$company['name'] = ($dec !== false && $dec !== '') ? $dec : $company['name'];
|
||||||
|
|
||||||
|
// 3. Monthly Invoice Stats (including tax)
|
||||||
|
$stmtStats = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(invoice_date, '%Y-%m') as month,
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax
|
||||||
|
FROM invoices
|
||||||
|
WHERE company_id = ? AND deleted_at IS NULL
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month DESC
|
||||||
|
LIMIT 12
|
||||||
|
");
|
||||||
|
$stmtStats->execute([$companyId]);
|
||||||
|
$monthly = $stmtStats->fetchAll();
|
||||||
|
|
||||||
|
// 4. Lifetime Totals
|
||||||
|
$stmtTotals = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax,
|
||||||
|
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count
|
||||||
|
FROM invoices
|
||||||
|
WHERE company_id = ? AND deleted_at IS NULL
|
||||||
|
");
|
||||||
|
$stmtTotals->execute([$companyId]);
|
||||||
|
$totals = $stmtTotals->fetch();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'company' => $company,
|
||||||
|
'monthly' => $monthly,
|
||||||
|
'totals' => $totals
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Company Stats Error: " . $e->getMessage());
|
||||||
|
json_error('Server error', 500);
|
||||||
|
}
|
||||||
80
app/modules_app/companies/update.php
Normal file
80
app/modules_app/companies/update.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Update Company Endpoint
|
||||||
|
* POST /v1/companies/update
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
|
||||||
|
$data = input();
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) json_error('معرّف الشركة مطلوب', 422);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
$query = $role === 'super_admin'
|
||||||
|
? "SELECT * FROM companies WHERE id = ?"
|
||||||
|
: "SELECT * FROM companies WHERE id = ? AND tenant_id = ?";
|
||||||
|
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) json_error('الشركة غير موجودة', 404);
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = ?';
|
||||||
|
$values[] = Encryption::encrypt($data['name']);
|
||||||
|
}
|
||||||
|
if (isset($data['name_en'])) {
|
||||||
|
$fields[] = 'name_en = ?';
|
||||||
|
$values[] = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null;
|
||||||
|
}
|
||||||
|
if (isset($data['tax_identification_number'])) {
|
||||||
|
$fields[] = 'tax_identification_number = ?';
|
||||||
|
$values[] = $data['tax_identification_number'];
|
||||||
|
}
|
||||||
|
if (isset($data['commercial_registration_number'])) {
|
||||||
|
$fields[] = 'commercial_registration_number = ?';
|
||||||
|
$values[] = $data['commercial_registration_number'];
|
||||||
|
}
|
||||||
|
if (isset($data['address'])) {
|
||||||
|
$fields[] = 'address = ?';
|
||||||
|
$values[] = $data['address'];
|
||||||
|
}
|
||||||
|
if (isset($data['city'])) {
|
||||||
|
$fields[] = 'city = ?';
|
||||||
|
$values[] = $data['city'];
|
||||||
|
}
|
||||||
|
if (isset($data['contact_email'])) {
|
||||||
|
$fields[] = 'contact_email = ?';
|
||||||
|
$values[] = $data['contact_email'];
|
||||||
|
}
|
||||||
|
if (isset($data['contact_phone'])) {
|
||||||
|
$fields[] = 'contact_phone = ?';
|
||||||
|
$values[] = $data['contact_phone'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) json_error('لا توجد بيانات للتحديث', 422);
|
||||||
|
|
||||||
|
$fields[] = 'updated_at = NOW()';
|
||||||
|
$values[] = $id;
|
||||||
|
|
||||||
|
$sql = "UPDATE companies SET " . implode(', ', $fields) . " WHERE id = ?";
|
||||||
|
$db->prepare($sql)->execute($values);
|
||||||
|
|
||||||
|
AuditLogger::log('company.updated', 'company', $id, null, ['fields' => array_keys($data)], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم تحديث بيانات الشركة بنجاح');
|
||||||
80
app/modules_app/dashboard/ai_usage.php
Normal file
80
app/modules_app/dashboard/ai_usage.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Usage Statistics
|
||||||
|
* GET /v1/dashboard/ai-usage
|
||||||
|
* Returns token consumption and cost breakdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Overall stats
|
||||||
|
$overall = AI::getUsageStats();
|
||||||
|
|
||||||
|
// Today's usage
|
||||||
|
$todayStmt = $db->query("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(cost_jod), 0) as cost_jod
|
||||||
|
FROM ai_usage_log
|
||||||
|
WHERE DATE(created_at) = CURDATE()
|
||||||
|
");
|
||||||
|
$today = $todayStmt->fetch();
|
||||||
|
|
||||||
|
// This month
|
||||||
|
$monthStmt = $db->query("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(cost_jod), 0) as cost_jod
|
||||||
|
FROM ai_usage_log
|
||||||
|
WHERE MONTH(created_at) = MONTH(NOW()) AND YEAR(created_at) = YEAR(NOW())
|
||||||
|
");
|
||||||
|
$month = $monthStmt->fetch();
|
||||||
|
|
||||||
|
// Daily breakdown (last 30 days)
|
||||||
|
$dailyStmt = $db->query("
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) as date,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(total_tokens) as tokens,
|
||||||
|
SUM(cost_jod) as cost_jod
|
||||||
|
FROM ai_usage_log
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date DESC
|
||||||
|
");
|
||||||
|
$daily = $dailyStmt->fetchAll();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'overall' => [
|
||||||
|
'total_requests' => (int)($overall['total_requests'] ?? 0),
|
||||||
|
'total_tokens' => (int)($overall['total_tokens'] ?? 0),
|
||||||
|
'total_cost_usd' => round((float)($overall['total_cost_usd'] ?? 0), 4),
|
||||||
|
'total_cost_jod' => round((float)($overall['total_cost_jod'] ?? 0), 4),
|
||||||
|
'avg_tokens_per_invoice' => round((float)($overall['avg_tokens_per_request'] ?? 0)),
|
||||||
|
'avg_cost_per_invoice_jod' => round((float)($overall['avg_cost_jod_per_request'] ?? 0), 6),
|
||||||
|
],
|
||||||
|
'today' => [
|
||||||
|
'requests' => (int)($today['requests'] ?? 0),
|
||||||
|
'tokens' => (int)($today['tokens'] ?? 0),
|
||||||
|
'cost_jod' => round((float)($today['cost_jod'] ?? 0), 4),
|
||||||
|
],
|
||||||
|
'this_month' => [
|
||||||
|
'requests' => (int)($month['requests'] ?? 0),
|
||||||
|
'tokens' => (int)($month['tokens'] ?? 0),
|
||||||
|
'cost_jod' => round((float)($month['cost_jod'] ?? 0), 4),
|
||||||
|
],
|
||||||
|
'daily_breakdown' => $daily,
|
||||||
|
], 'إحصائيات استخدام الذكاء الاصطناعي');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("AI Usage Stats Error: " . $e->getMessage() . " | " . $e->getTraceAsString());
|
||||||
|
safe_error($e, 'dashboard/ai_usage', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.');
|
||||||
|
}
|
||||||
183
app/modules_app/dashboard/recent_activity.php
Normal file
183
app/modules_app/dashboard/recent_activity.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Dashboard Recent Activity Endpoint
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
$where = "WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
} else {
|
||||||
|
$where = "WHERE a.tenant_id = ?";
|
||||||
|
$params = [$tenantId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.action,
|
||||||
|
a.entity_type,
|
||||||
|
a.entity_id,
|
||||||
|
a.new_data,
|
||||||
|
a.created_at,
|
||||||
|
u.name AS user_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
$where
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$activities = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($activities as &$activity) {
|
||||||
|
$activity['user_name'] = decryptIfEncrypted($activity['user_name'] ?? null) ?: 'مستخدم مجهول';
|
||||||
|
$activity['details'] = decodeAuditData($activity['new_data'] ?? null);
|
||||||
|
$activity['summary'] = buildActivitySummary($activity);
|
||||||
|
unset($activity['new_data']);
|
||||||
|
}
|
||||||
|
unset($activity);
|
||||||
|
|
||||||
|
json_success($activities);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Dashboard Recent Activity Error: ' . $e->getMessage());
|
||||||
|
json_error('Failed to fetch recent activity', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAuditData(?string $json): array
|
||||||
|
{
|
||||||
|
if (!$json) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decryptAuditPayload($decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptAuditPayload(array $payload): array
|
||||||
|
{
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$payload[$key] = decryptAuditPayload($value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$payload[$key] = decryptIfEncrypted($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptIfEncrypted(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string)$value);
|
||||||
|
if ($text === '' || !looksEncrypted($text)) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decrypted = Encryption::decrypt($text);
|
||||||
|
return $decrypted !== false ? $decrypted : $text;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksEncrypted(string $value): bool
|
||||||
|
{
|
||||||
|
$normalized = str_starts_with($value, '==') ? substr($value, 2) : $value;
|
||||||
|
|
||||||
|
if (strlen($normalized) < 40 || strlen($normalized) % 4 !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool)preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActivitySummary(array $activity): string
|
||||||
|
{
|
||||||
|
$data = is_array($activity['details'] ?? null) ? $activity['details'] : [];
|
||||||
|
$action = (string)($activity['action'] ?? '');
|
||||||
|
|
||||||
|
return match ($action) {
|
||||||
|
'payment.created' => buildPaymentSummary('تم إنشاء طلب دفع', $data),
|
||||||
|
'payment.approved' => buildPaymentSummary('تم اعتماد طلب دفع', $data),
|
||||||
|
'payment.rejected' => buildPaymentSummary('تم رفض طلب دفع', $data),
|
||||||
|
'subscription.activated' => buildPaymentSummary('تم تفعيل الاشتراك', $data),
|
||||||
|
'invoice.approved' => buildEntitySummary('تم اعتماد الفاتورة', $data, ['invoice_number', 'number']),
|
||||||
|
'invoice.extracted' => buildEntitySummary('تم استخراج بيانات الفاتورة', $data, ['invoice_number', 'number']),
|
||||||
|
'company.created' => buildEntitySummary('تمت إضافة شركة', $data, ['name', 'company_name']),
|
||||||
|
'user.created' => buildEntitySummary('تمت إضافة مستخدم', $data, ['name', 'email']),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaymentSummary(string $label, array $data): string
|
||||||
|
{
|
||||||
|
$parts = [$label];
|
||||||
|
|
||||||
|
$plan = firstTextValue($data, ['plan_name', 'plan_name_ar', 'plan_id']);
|
||||||
|
if ($plan !== '') {
|
||||||
|
$parts[] = "الباقة: {$plan}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = firstTextValue($data, ['amount', 'amount_jod', 'price_jod']);
|
||||||
|
if ($amount !== '') {
|
||||||
|
$parts[] = "القيمة: {$amount} د.أ";
|
||||||
|
}
|
||||||
|
|
||||||
|
$reference = firstTextValue($data, ['ref', 'reference', 'internal_reference']);
|
||||||
|
if ($reference !== '') {
|
||||||
|
$parts[] = "المرجع: {$reference}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' - ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntitySummary(string $label, array $data, array $keys): string
|
||||||
|
{
|
||||||
|
$value = firstTextValue($data, $keys);
|
||||||
|
return $value === '' ? $label : "{$label}: {$value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstTextValue(array $data, array $keys): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!array_key_exists($key, $data) || $data[$key] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $data[$key];
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$text = trim((string)$value);
|
||||||
|
if ($text !== '') {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
81
app/modules_app/dashboard/stats.php
Normal file
81
app/modules_app/dashboard/stats.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Dashboard Stats Endpoint (Role-Based & Tenant-Aware)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
$companyId = $decoded['company_id'] ?? null;
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stats = [
|
||||||
|
'role' => $role,
|
||||||
|
'invoices' => [
|
||||||
|
'total' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
'approved' => 0
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Fetch Invoice Stats
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
$where = "WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
} elseif ($role === 'accountant' || $role === 'viewer') {
|
||||||
|
$where = "WHERE tenant_id = ? AND company_id = ?";
|
||||||
|
$params = [$tenantId, $companyId];
|
||||||
|
} else {
|
||||||
|
// admin
|
||||||
|
$where = "WHERE tenant_id = ?";
|
||||||
|
$params = [$tenantId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$stats['invoices']['total'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$stats['invoices']['pending'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$stats['invoices']['approved'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
// 3. Role-Specific Extra Stats
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
$stats['tenants'] = (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn();
|
||||||
|
$stats['total_users'] = (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn();
|
||||||
|
} elseif ($role === 'admin') {
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ?");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$stats['companies'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ?");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$stats['users'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
// Get Subscription Quota
|
||||||
|
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
if ($sub) {
|
||||||
|
$stats['subscription'] = [
|
||||||
|
'limit' => (int)$sub['max_invoices_per_month'],
|
||||||
|
'used' => (int)$sub['invoices_used_this_month']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Return default zeroed stats on error
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($stats);
|
||||||
124
app/modules_app/excel/import.php
Normal file
124
app/modules_app/excel/import.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Bulk Excel Import Endpoint
|
||||||
|
* POST /v1/excel/import
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
json_error('الملف مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyId = input('company_id');
|
||||||
|
if (!$companyId) {
|
||||||
|
json_error('يجب تحديد الشركة', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
$tmpPath = $file['tmp_name'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$spreadsheet = IOFactory::load($tmpPath);
|
||||||
|
$worksheet = $spreadsheet->getActiveSheet();
|
||||||
|
$rows = $worksheet->toArray();
|
||||||
|
|
||||||
|
if (count($rows) < 2) {
|
||||||
|
json_error('الملف فارغ أو لا يحتوي على بيانات', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = array_shift($rows);
|
||||||
|
$mapping = mapColumns($header);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$importedCount = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($rows as $index => $row) {
|
||||||
|
if (empty(array_filter($row))) continue; // Skip empty rows
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check quota for each invoice (preventive)
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
QuotaMiddleware::checkInvoiceQuota($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceData = [
|
||||||
|
'id' => Database::generateUuid(),
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'invoice_number' => $row[$mapping['number']] ?? 'EXT-' . time() . '-' . $index,
|
||||||
|
'invoice_date' => formatDate($row[$mapping['date']] ?? date('Y-m-d')),
|
||||||
|
'customer_name' => Encryption::encrypt($row[$mapping['customer']] ?? 'عميل عام'),
|
||||||
|
'grand_total' => (float)($row[$mapping['total']] ?? 0),
|
||||||
|
'tax_amount' => (float)($row[$mapping['tax']] ?? 0),
|
||||||
|
'status' => 'extracted', // Ready for review/approval
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoices (id, tenant_id, company_id, invoice_number, invoice_date, customer_name, grand_total, tax_amount, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute(array_values($invoiceData));
|
||||||
|
$importedCount++;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errors[] = "السطر " . ($index + 2) . ": " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'imported_count' => $importedCount,
|
||||||
|
'errors' => $errors
|
||||||
|
], "تم استيراد $importedCount فاتورة بنجاح");
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (isset($db)) $db->rollBack();
|
||||||
|
safe_error($e, 'excel/import', 'فشل معالجة ملف الإكسل.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intelligent Column Mapping
|
||||||
|
*/
|
||||||
|
function mapColumns(array $header): array {
|
||||||
|
$map = [
|
||||||
|
'number' => 0,
|
||||||
|
'date' => 1,
|
||||||
|
'customer' => 2,
|
||||||
|
'total' => 3,
|
||||||
|
'tax' => 4
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($header as $i => $col) {
|
||||||
|
$col = mb_strtolower(trim((string)$col));
|
||||||
|
if (str_contains($col, 'رقم') || str_contains($col, 'number')) $map['number'] = $i;
|
||||||
|
if (str_contains($col, 'تاريخ') || str_contains($col, 'date')) $map['date'] = $i;
|
||||||
|
if (str_contains($col, 'عميل') || str_contains($col, 'customer') || str_contains($col, 'اسم')) $map['customer'] = $i;
|
||||||
|
if (str_contains($col, 'اجمالي') || str_contains($col, 'total') || str_contains($col, 'المجموع')) $map['total'] = $i;
|
||||||
|
if (str_contains($col, 'ضريبة') || str_contains($col, 'tax')) $map['tax'] = $i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate($val): string {
|
||||||
|
if (is_numeric($val)) {
|
||||||
|
return date('Y-m-d', \PhpOffice\PhpSpreadsheet\Shared\Date::excelToTimestamp($val));
|
||||||
|
}
|
||||||
|
$ts = strtotime((string)$val);
|
||||||
|
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
||||||
|
}
|
||||||
15
app/modules_app/gamification/profile.php
Normal file
15
app/modules_app/gamification/profile.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gamification Profile
|
||||||
|
* GET /v1/gamification/profile
|
||||||
|
* Returns user's points, level, badges, and progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Services\GamificationService;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
$profile = GamificationService::getProfile($decoded['user_id'], $decoded['tenant_id']);
|
||||||
|
|
||||||
|
json_success($profile, 'ملفك التنافسي');
|
||||||
143
app/modules_app/invoices/approve.php
Normal file
143
app/modules_app/invoices/approve.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Approve Invoice & Submit to JoFotara
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\JoFotara;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Middleware\CompanyAccessMiddleware;
|
||||||
|
|
||||||
|
// Only admin, accountant, and super_admin can approve. Viewers cannot.
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
json_error('Invoice ID is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Fetch Invoice & Company
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*, c.name as company_name, c.tax_identification_number as company_tin,
|
||||||
|
c.address as company_address, c.jofotara_client_id_encrypted, c.jofotara_secret_key_encrypted
|
||||||
|
FROM invoices i
|
||||||
|
JOIN companies c ON i.company_id = c.id
|
||||||
|
WHERE i.id = ? FOR UPDATE
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) json_error('Invoice not found', 404);
|
||||||
|
if ($invoice['status'] === 'approved') json_error('Already approved', 400);
|
||||||
|
|
||||||
|
// 2. Fetch Line Items
|
||||||
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?");
|
||||||
|
$stmtLines->execute([$id]);
|
||||||
|
$invoice['items'] = $stmtLines->fetchAll();
|
||||||
|
|
||||||
|
// 3. Decrypt Company Keys for JoFotara
|
||||||
|
$clientId = \App\Core\Encryption::decrypt($invoice['jofotara_client_id_encrypted'] ?? '');
|
||||||
|
$secretKey = \App\Core\Encryption::decrypt($invoice['jofotara_secret_key_encrypted'] ?? '');
|
||||||
|
|
||||||
|
$jofotara = new JoFotara();
|
||||||
|
$apiResponse = ['success' => false];
|
||||||
|
$xmlContent = null;
|
||||||
|
|
||||||
|
// 4. Try JoFotara Submission if credentials exist
|
||||||
|
if ($clientId && $secretKey) {
|
||||||
|
$companyData = [
|
||||||
|
'name' => $invoice['company_name'],
|
||||||
|
'tax_identification_number' => $invoice['company_tin'],
|
||||||
|
'address' => $invoice['company_address']
|
||||||
|
];
|
||||||
|
|
||||||
|
// Decrypt Buyer Info for XML
|
||||||
|
$invoice['buyer_name'] = \App\Core\Encryption::decrypt($invoice['buyer_name'] ?? '') ?: ($invoice['buyer_name'] ?? '');
|
||||||
|
$invoice['buyer_tin'] = \App\Core\Encryption::decrypt($invoice['buyer_tin'] ?? '') ?: ($invoice['buyer_tin'] ?? '');
|
||||||
|
|
||||||
|
$xmlContent = $jofotara->generateXML($invoice, $companyData);
|
||||||
|
$apiResponse = $jofotara->submitInvoice($xmlContent, $clientId, $secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback: Generate Local QR if API failed or no credentials
|
||||||
|
$qrCode = $apiResponse['qrCode'] ?? $jofotara->generateQRCode([
|
||||||
|
'supplier_name' => $invoice['company_name'],
|
||||||
|
'supplier_tin' => $invoice['company_tin'],
|
||||||
|
'invoice_date' => $invoice['invoice_date'],
|
||||||
|
'grand_total' => $invoice['grand_total'],
|
||||||
|
'tax_amount' => $invoice['tax_amount']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. Record Submission (Audit Log)
|
||||||
|
$submissionId = \App\Core\Database::generateUuid();
|
||||||
|
$stmtSub = $db->prepare("
|
||||||
|
INSERT INTO jofotara_submissions
|
||||||
|
(id, invoice_id, company_id, tenant_id, xml_payload, xml_hash,
|
||||||
|
jofotara_uuid, qr_code_raw, response_code, response_body, status, submitted_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
");
|
||||||
|
|
||||||
|
$status = $apiResponse['success'] ? 'accepted' : 'error';
|
||||||
|
$stmtSub->execute([
|
||||||
|
$submissionId,
|
||||||
|
$id,
|
||||||
|
$invoice['company_id'],
|
||||||
|
$invoice['tenant_id'],
|
||||||
|
$xmlContent,
|
||||||
|
$xmlContent ? hash('sha256', $xmlContent) : null,
|
||||||
|
$apiResponse['uuid'] ?? null,
|
||||||
|
$qrCode,
|
||||||
|
$apiResponse['_http_code'] ?? '0',
|
||||||
|
json_encode($apiResponse['raw'] ?? ['info' => 'Local approval / No credentials']),
|
||||||
|
$status
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 7. Update Invoice
|
||||||
|
$updateStmt = $db->prepare("
|
||||||
|
UPDATE invoices SET status = 'approved', jofotara_uuid = ?, qr_code = ?, updated_at = NOW() WHERE id = ?
|
||||||
|
");
|
||||||
|
$updateStmt->execute([$apiResponse['uuid'] ?? null, $qrCode, $id]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'message' => $apiResponse['success'] ? 'تم الاعتماد والإرسال إلى جوفوترة بنجاح' : 'تم الاعتماد محلياً (نظام جوفوترة غير متصل)',
|
||||||
|
'uuid' => $apiResponse['uuid'] ?? null,
|
||||||
|
'qr_code' => $qrCode,
|
||||||
|
'is_api_success' => $apiResponse['success']
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.approved', 'invoice', $id, [
|
||||||
|
'old_status' => $invoice['status'],
|
||||||
|
], [
|
||||||
|
'new_status' => 'approved',
|
||||||
|
'jofotara_uuid' => $apiResponse['uuid'] ?? null,
|
||||||
|
'api_success' => $apiResponse['success'],
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
// Smart Notifications
|
||||||
|
\App\Services\SmartNotifications::invoiceApproved(
|
||||||
|
$invoice['tenant_id'], $invoice['uploaded_by'] ?? $decoded['user_id'],
|
||||||
|
$id, $invoice['invoice_number'] ?? $id
|
||||||
|
);
|
||||||
|
\App\Services\SmartNotifications::checkQuotaWarning($invoice['tenant_id']);
|
||||||
|
|
||||||
|
// Gamification
|
||||||
|
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'invoice_approved');
|
||||||
|
if ($apiResponse['success'] ?? false) {
|
||||||
|
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'jofotara_submitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("JoFotara Approve Error: " . $e->getMessage());
|
||||||
|
safe_error($e, 'invoices/approve');
|
||||||
|
}
|
||||||
62
app/modules_app/invoices/bulk_approve.php
Normal file
62
app/modules_app/invoices/bulk_approve.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Bulk Approve Invoices
|
||||||
|
* POST /v1/invoices/bulk-approve
|
||||||
|
* Approves multiple invoices at once
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
|
||||||
|
$data = input();
|
||||||
|
|
||||||
|
$ids = $data['ids'] ?? [];
|
||||||
|
if (empty($ids) || !is_array($ids)) {
|
||||||
|
json_error('يرجى اختيار فاتورة واحدة على الأقل', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
$approved = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
try {
|
||||||
|
// Verify access
|
||||||
|
$query = $role === 'super_admin'
|
||||||
|
? "SELECT id, status FROM invoices WHERE id = ? AND status = 'extracted'"
|
||||||
|
: "SELECT id, status FROM invoices WHERE id = ? AND tenant_id = ? AND status = 'extracted'";
|
||||||
|
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
|
||||||
|
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
$errors[] = "$id: غير موجودة أو معتمدة مسبقاً";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("UPDATE invoices SET status = 'approved', updated_at = NOW() WHERE id = ?")
|
||||||
|
->execute([$id]);
|
||||||
|
|
||||||
|
$approved++;
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.bulk_approved', 'invoice', $id, null, [
|
||||||
|
'batch_size' => count($ids),
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errors[] = "$id: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'approved_count' => $approved,
|
||||||
|
'total_requested' => count($ids),
|
||||||
|
'errors' => $errors,
|
||||||
|
], "تم اعتماد $approved فاتورة بنجاح");
|
||||||
130
app/modules_app/invoices/check_duplicate.php
Normal file
130
app/modules_app/invoices/check_duplicate.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Check Duplicate Invoices
|
||||||
|
* POST /v1/invoices/check-duplicate
|
||||||
|
* Checks if similar invoice exists before processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$data = input();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$invoiceNumber = $data['invoice_number'] ?? null;
|
||||||
|
$supplierTin = $data['supplier_tin'] ?? null;
|
||||||
|
$grandTotal = $data['grand_total'] ?? null;
|
||||||
|
$invoiceDate = $data['invoice_date'] ?? null;
|
||||||
|
$excludeId = $data['exclude_id'] ?? null;
|
||||||
|
|
||||||
|
$duplicates = [];
|
||||||
|
|
||||||
|
// 1. Exact match on invoice number
|
||||||
|
if ($invoiceNumber) {
|
||||||
|
$sql = "SELECT id, invoice_number, invoice_date, grand_total, status, supplier_name
|
||||||
|
FROM invoices WHERE invoice_number = ? AND tenant_id = ?";
|
||||||
|
$params = [$invoiceNumber, $tenantId];
|
||||||
|
|
||||||
|
if ($excludeId) {
|
||||||
|
$sql .= " AND id != ?";
|
||||||
|
$params[] = $excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$matches = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($matches as $m) {
|
||||||
|
$decName = Encryption::decrypt($m['supplier_name']);
|
||||||
|
$duplicates[] = [
|
||||||
|
'id' => $m['id'],
|
||||||
|
'invoice_number' => $m['invoice_number'],
|
||||||
|
'invoice_date' => $m['invoice_date'],
|
||||||
|
'grand_total' => $m['grand_total'],
|
||||||
|
'status' => $m['status'],
|
||||||
|
'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'],
|
||||||
|
'match_type' => 'exact_number',
|
||||||
|
'confidence' => 100,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fuzzy match: same supplier TIN + same total + same date
|
||||||
|
if ($supplierTin && $grandTotal && $invoiceDate && empty($duplicates)) {
|
||||||
|
$sql = "SELECT id, invoice_number, invoice_date, grand_total, status, supplier_name, supplier_tin
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
AND invoice_date = ?
|
||||||
|
AND ABS(grand_total - ?) < 0.01";
|
||||||
|
$params = [$tenantId, $invoiceDate, $grandTotal];
|
||||||
|
|
||||||
|
if ($excludeId) {
|
||||||
|
$sql .= " AND id != ?";
|
||||||
|
$params[] = $excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$matches = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($matches as $m) {
|
||||||
|
$decTin = Encryption::decrypt($m['supplier_tin']);
|
||||||
|
$decName = Encryption::decrypt($m['supplier_name']);
|
||||||
|
|
||||||
|
if ($decTin === $supplierTin || $m['supplier_tin'] === $supplierTin) {
|
||||||
|
$duplicates[] = [
|
||||||
|
'id' => $m['id'],
|
||||||
|
'invoice_number' => $m['invoice_number'],
|
||||||
|
'invoice_date' => $m['invoice_date'],
|
||||||
|
'grand_total' => $m['grand_total'],
|
||||||
|
'status' => $m['status'],
|
||||||
|
'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'],
|
||||||
|
'match_type' => 'fuzzy_tin_total_date',
|
||||||
|
'confidence' => 90,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Near match: same total + near date (±3 days)
|
||||||
|
if ($grandTotal && $invoiceDate && empty($duplicates)) {
|
||||||
|
$sql = "SELECT id, invoice_number, invoice_date, grand_total, status, supplier_name
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
AND ABS(grand_total - ?) < 0.01
|
||||||
|
AND ABS(DATEDIFF(invoice_date, ?)) <= 3";
|
||||||
|
$params = [$tenantId, $grandTotal, $invoiceDate];
|
||||||
|
|
||||||
|
if ($excludeId) {
|
||||||
|
$sql .= " AND id != ?";
|
||||||
|
$params[] = $excludeId;
|
||||||
|
}
|
||||||
|
$sql .= " LIMIT 5";
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$matches = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($matches as $m) {
|
||||||
|
$decName = Encryption::decrypt($m['supplier_name']);
|
||||||
|
$duplicates[] = [
|
||||||
|
'id' => $m['id'],
|
||||||
|
'invoice_number' => $m['invoice_number'],
|
||||||
|
'invoice_date' => $m['invoice_date'],
|
||||||
|
'grand_total' => $m['grand_total'],
|
||||||
|
'status' => $m['status'],
|
||||||
|
'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'],
|
||||||
|
'match_type' => 'near_total_date',
|
||||||
|
'confidence' => 60,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'is_duplicate' => !empty($duplicates),
|
||||||
|
'matches' => $duplicates,
|
||||||
|
'count' => count($duplicates),
|
||||||
|
], empty($duplicates) ? 'لا توجد فواتير مكررة' : 'تم العثور على فواتير مشابهة');
|
||||||
49
app/modules_app/invoices/delete.php
Normal file
49
app/modules_app/invoices/delete.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Delete Invoice
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
json_error('Invoice ID is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) json_error('Invoice not found', 404);
|
||||||
|
|
||||||
|
// Super admin can delete anything. Others might only delete non-approved, but let's allow admin to delete.
|
||||||
|
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
|
||||||
|
json_error('Access denied', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("DELETE FROM invoice_lines WHERE invoice_id = ?")->execute([$id]);
|
||||||
|
$db->prepare("DELETE FROM jofotara_submissions WHERE invoice_id = ?")->execute([$id]);
|
||||||
|
$db->prepare("DELETE FROM invoices WHERE id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.deleted', 'invoice', $id, null, null, $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم حذف الفاتورة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Invoice Delete Error: " . $e->getMessage());
|
||||||
|
json_error('فشل في حذف الفاتورة', 500);
|
||||||
|
}
|
||||||
44
app/modules_app/invoices/download_xml.php
Normal file
44
app/modules_app/invoices/download_xml.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Official JoFotara XML Download
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 2. Validate Request
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if (!$id) json_error('Invoice ID is required', 422);
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Fetch accepted submission for this invoice
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT js.xml_payload, js.jofotara_uuid
|
||||||
|
FROM jofotara_submissions js
|
||||||
|
JOIN invoices i ON js.invoice_id = i.id
|
||||||
|
WHERE i.id = ? AND i.tenant_id = ? AND js.status = 'accepted'
|
||||||
|
ORDER BY js.created_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$id, $tenantId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row || empty($row['xml_payload'])) {
|
||||||
|
json_error('لا يوجد XML رسمي متاح لهذه الفاتورة', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Send headers for download
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="invoice_' . ($row['jofotara_uuid'] ?: $id) . '.xml"');
|
||||||
|
echo $row['xml_payload'];
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("XML Download Error: " . $e->getMessage());
|
||||||
|
json_error('خطأ في تحميل الملف', 500);
|
||||||
|
}
|
||||||
133
app/modules_app/invoices/export.php
Normal file
133
app/modules_app/invoices/export.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Export Invoices as CSV (Excel-compatible)
|
||||||
|
* GET /v1/invoices/export
|
||||||
|
* Downloads a CSV file with invoice data + line items
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$companyId = $_GET['company_id'] ?? null;
|
||||||
|
$dateFrom = $_GET['date_from'] ?? null;
|
||||||
|
$dateTo = $_GET['date_to'] ?? null;
|
||||||
|
$status = $_GET['status'] ?? null;
|
||||||
|
|
||||||
|
// Build query with filters
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'i.tenant_id = ?';
|
||||||
|
$params[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($companyId) {
|
||||||
|
$where[] = 'i.company_id = ?';
|
||||||
|
$params[] = $companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateFrom) {
|
||||||
|
$where[] = 'i.invoice_date >= ?';
|
||||||
|
$params[] = $dateFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateTo) {
|
||||||
|
$where[] = 'i.invoice_date <= ?';
|
||||||
|
$params[] = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status) {
|
||||||
|
$where[] = 'i.status = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*, c.name as company_name_raw
|
||||||
|
FROM invoices i
|
||||||
|
JOIN companies c ON i.company_id = c.id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY i.invoice_date DESC
|
||||||
|
LIMIT 5000
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Decrypt helper
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
// UTF-8 BOM for Excel compatibility
|
||||||
|
$output = "\xEF\xBB\xBF";
|
||||||
|
|
||||||
|
// CSV headers
|
||||||
|
$output .= implode(',', [
|
||||||
|
'رقم الفاتورة',
|
||||||
|
'تاريخ الفاتورة',
|
||||||
|
'الشركة',
|
||||||
|
'اسم المورّد',
|
||||||
|
'الرقم الضريبي للمورّد',
|
||||||
|
'عنوان المورّد',
|
||||||
|
'اسم العميل',
|
||||||
|
'الرقم الضريبي للعميل',
|
||||||
|
'نوع الفاتورة',
|
||||||
|
'المبلغ قبل الضريبة',
|
||||||
|
'قيمة الخصم',
|
||||||
|
'قيمة الضريبة',
|
||||||
|
'الإجمالي',
|
||||||
|
'العملة',
|
||||||
|
'الحالة',
|
||||||
|
'JoFotara UUID',
|
||||||
|
'تاريخ الإنشاء',
|
||||||
|
]) . "\n";
|
||||||
|
|
||||||
|
foreach ($invoices as $inv) {
|
||||||
|
$statusAr = match($inv['status']) {
|
||||||
|
'extracted' => 'مستخرجة',
|
||||||
|
'approved' => 'معتمدة',
|
||||||
|
'submitted' => 'مقدمة لجوفتورة',
|
||||||
|
'rejected' => 'مرفوضة',
|
||||||
|
default => $inv['status']
|
||||||
|
};
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'"' . str_replace('"', '""', $inv['invoice_number'] ?? '') . '"',
|
||||||
|
$inv['invoice_date'] ?? '',
|
||||||
|
'"' . str_replace('"', '""', $dec($inv['company_name_raw'] ?? '')) . '"',
|
||||||
|
'"' . str_replace('"', '""', $dec($inv['supplier_name'])) . '"',
|
||||||
|
'"' . $dec($inv['supplier_tin']) . '"',
|
||||||
|
'"' . str_replace('"', '""', $dec($inv['supplier_address'])) . '"',
|
||||||
|
'"' . str_replace('"', '""', $dec($inv['buyer_name'])) . '"',
|
||||||
|
'"' . $dec($inv['buyer_tin']) . '"',
|
||||||
|
$inv['invoice_type'] ?? 'cash',
|
||||||
|
$inv['subtotal'] ?? '0',
|
||||||
|
$inv['discount_total'] ?? '0',
|
||||||
|
$inv['tax_amount'] ?? '0',
|
||||||
|
$inv['grand_total'] ?? '0',
|
||||||
|
$inv['currency_code'] ?? 'JOD',
|
||||||
|
$statusAr,
|
||||||
|
$inv['jofotara_uuid'] ?? '',
|
||||||
|
$inv['created_at'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$output .= implode(',', $row) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send as download
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="musadaq_invoices_' . date('Y-m-d') . '.csv"');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
|
||||||
|
echo $output;
|
||||||
|
exit;
|
||||||
532
app/modules_app/invoices/export_excel.php
Normal file
532
app/modules_app/invoices/export_excel.php
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Export Invoices as Professional Excel (.xlsx) with Formulas
|
||||||
|
* GET /v1/invoices/export-excel
|
||||||
|
*
|
||||||
|
* Generates a real .xlsx file with:
|
||||||
|
* - Invoice header info + line items
|
||||||
|
* - Excel formulas for subtotals, tax, discount, net
|
||||||
|
* - SUM row at the bottom
|
||||||
|
* - Professional formatting (colors, borders, Arabic RTL)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Color;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
|
||||||
|
|
||||||
|
// Enable error reporting for debugging
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Autoload PhpSpreadsheet
|
||||||
|
require_once ROOT_PATH . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Auth: Support both Bearer header and ?token= query param (for download links)
|
||||||
|
$token = $_GET['token'] ?? null;
|
||||||
|
if (!$token) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$token) json_error('غير مصرح: لا يوجد رمز دخول', 401);
|
||||||
|
|
||||||
|
$decoded = \App\Core\JWT::decode($token, env('JWT_SECRET', ''));
|
||||||
|
if (!$decoded) json_error('غير مصرح: رمز دخول غير صالح', 401);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$companyId = $_GET['company_id'] ?? null;
|
||||||
|
$dateFrom = $_GET['date_from'] ?? null;
|
||||||
|
$dateTo = $_GET['date_to'] ?? null;
|
||||||
|
$status = $_GET['status'] ?? null;
|
||||||
|
$invoiceId = $_GET['invoice_id'] ?? null; // Single invoice export
|
||||||
|
|
||||||
|
// Build query with filters
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'i.tenant_id = ?';
|
||||||
|
$params[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invoiceId) {
|
||||||
|
$where[] = 'i.id = ?';
|
||||||
|
$params[] = $invoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($companyId) {
|
||||||
|
$where[] = 'i.company_id = ?';
|
||||||
|
$params[] = $companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateFrom) {
|
||||||
|
$where[] = 'i.invoice_date >= ?';
|
||||||
|
$params[] = $dateFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateTo) {
|
||||||
|
$where[] = 'i.invoice_date <= ?';
|
||||||
|
$params[] = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status) {
|
||||||
|
$where[] = 'i.status = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*, c.name as company_name_raw
|
||||||
|
FROM invoices i
|
||||||
|
JOIN companies c ON i.company_id = c.id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY i.invoice_date DESC
|
||||||
|
LIMIT 5000
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
|
if (empty($invoices)) {
|
||||||
|
json_error('لا توجد فواتير لتصديرها', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt helper
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Robust download helper for QR codes
|
||||||
|
$downloadUrl = function($url) {
|
||||||
|
$data = @file_get_contents($url);
|
||||||
|
if ($data === false && function_exists('curl_init')) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
$data = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// BUILD SPREADSHEET
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
$spreadsheet = new Spreadsheet();
|
||||||
|
$spreadsheet->getProperties()
|
||||||
|
->setCreator('مُصادَق - Musadaq')
|
||||||
|
->setTitle('تقرير الفواتير')
|
||||||
|
->setDescription('تقرير فواتير المشتريات - تم إنشاؤه تلقائياً من منصة مُصادَق');
|
||||||
|
|
||||||
|
// === COLORS ===
|
||||||
|
$headerBg = '1C1550'; // Deep violet
|
||||||
|
$headerFont = 'FFFFFF'; // White
|
||||||
|
$subHeaderBg = 'EDE9FE'; // Light violet
|
||||||
|
$subHeaderFont = '5B21B6'; // Violet
|
||||||
|
$totalBg = 'D1FAE5'; // Light green
|
||||||
|
$totalFont = '065F46'; // Dark green
|
||||||
|
$borderColor = 'E2E1F0'; // Light border
|
||||||
|
$altRowBg = 'F8F7FD'; // Alternating row
|
||||||
|
|
||||||
|
$logoPath = ROOT_PATH . '/public/assets/img/logo.jpg';
|
||||||
|
if (!file_exists($logoPath)) {
|
||||||
|
error_log("Excel Export Error: Logo not found at {$logoPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 1. SUMMARY SHEET (First Sheet)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
$summarySheet = $spreadsheet->getActiveSheet();
|
||||||
|
$summarySheet->setTitle('الملخص الإجمالي');
|
||||||
|
$summarySheet->setRightToLeft(true);
|
||||||
|
|
||||||
|
// --- SUMMARY HEADER ---
|
||||||
|
// We use A1 for Logo, B1:I1 for Title, J1 for Link/QR to avoid merge issues in some viewers
|
||||||
|
$summarySheet->setCellValue("B1", 'مُـصَـادَق — ملخص الفواتير الإجمالي');
|
||||||
|
$summarySheet->mergeCells("B1:I1");
|
||||||
|
$summarySheet->getStyle("B1:I1")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => 16, 'color' => ['argb' => 'FF' . $headerFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $headerBg]],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||||
|
]);
|
||||||
|
$summarySheet->getRowDimension(1)->setRowHeight(45);
|
||||||
|
|
||||||
|
// Style A1 and J1 background to match the header
|
||||||
|
$summarySheet->getStyle("A1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF' . $headerBg);
|
||||||
|
$summarySheet->getStyle("J1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF' . $headerBg);
|
||||||
|
|
||||||
|
// --- Add Logo ---
|
||||||
|
try {
|
||||||
|
if (file_exists($logoPath)) {
|
||||||
|
$logoSummary = new Drawing();
|
||||||
|
$logoSummary->setName('Musadaq Logo');
|
||||||
|
$logoSummary->setPath($logoPath);
|
||||||
|
$logoSummary->setHeight(38);
|
||||||
|
$logoSummary->setCoordinates('A1');
|
||||||
|
$logoSummary->setOffsetX(5);
|
||||||
|
$logoSummary->setOffsetY(5);
|
||||||
|
$logoSummary->setWorksheet($summarySheet);
|
||||||
|
}
|
||||||
|
} catch(\Exception $e) { error_log('Logo Summary Error: ' . $e->getMessage()); }
|
||||||
|
|
||||||
|
// --- Add Clickable Website Link ---
|
||||||
|
$summarySheet->setCellValue('J1', 'musadaq.intaleqapp.com/verify_qr');
|
||||||
|
$summarySheet->getCell('J1')->getHyperlink()->setUrl('https://musadaq.intaleqapp.com/index.php?route=verify_qr');
|
||||||
|
$summarySheet->getStyle("J1")->applyFromArray([
|
||||||
|
'font' => ['color' => ['argb' => 'FFFFFFFF'], 'underline' => true, 'size' => 9],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_LEFT, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Add QR Code to Summary Header ---
|
||||||
|
try {
|
||||||
|
$summaryUrl = "https://musadaq.intaleqapp.com/index.php?route=verify_qr";
|
||||||
|
$qrApiUrl = "https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=" . urlencode($summaryUrl);
|
||||||
|
$qrData = $downloadUrl($qrApiUrl);
|
||||||
|
if ($qrData) {
|
||||||
|
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_sum_');
|
||||||
|
file_put_contents($tmpQr, $qrData);
|
||||||
|
$drawingQr = new Drawing();
|
||||||
|
$drawingQr->setName('Musadaq QR');
|
||||||
|
$drawingQr->setPath($tmpQr);
|
||||||
|
$drawingQr->setHeight(38);
|
||||||
|
$drawingQr->setCoordinates('J1');
|
||||||
|
$drawingQr->setOffsetX(5);
|
||||||
|
$drawingQr->setOffsetY(5);
|
||||||
|
$drawingQr->setWorksheet($summarySheet);
|
||||||
|
}
|
||||||
|
} catch(\Exception $e) {}
|
||||||
|
|
||||||
|
// Summary Meta Info
|
||||||
|
$companyNameFilter = 'جميع الشركات';
|
||||||
|
if ($companyId) {
|
||||||
|
$cStmt = $db->prepare("SELECT name FROM companies WHERE id = ?");
|
||||||
|
$cStmt->execute([$companyId]);
|
||||||
|
$cName = $cStmt->fetchColumn();
|
||||||
|
if ($cName) $companyNameFilter = $dec($cName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summarySheet->setCellValue("A3", 'الشركة:');
|
||||||
|
$summarySheet->setCellValue("B3", $companyNameFilter);
|
||||||
|
$summarySheet->setCellValue("D3", 'الفترة:');
|
||||||
|
$summarySheet->setCellValue("E3", ($dateFrom ?? '—') . ' إلى ' . ($dateTo ?? '—'));
|
||||||
|
$summarySheet->setCellValue("G3", 'عدد الفواتير:');
|
||||||
|
$summarySheet->setCellValue("H3", count($invoices));
|
||||||
|
|
||||||
|
$summarySheet->getStyle("A3:H3")->getFont()->setBold(true);
|
||||||
|
|
||||||
|
// --- SUMMARY TABLE HEADERS ---
|
||||||
|
$row = 5;
|
||||||
|
$summaryHeaders = ['#', 'رقم الفاتورة', 'المورّد', 'وصف البند', 'الكمية', 'سعر الوحدة', 'المجموع الجزئي', 'نسبة الضريبة', 'قيمة الضريبة', 'الصافي'];
|
||||||
|
$sumCols = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
|
||||||
|
|
||||||
|
foreach ($summaryHeaders as $i => $h) {
|
||||||
|
$summarySheet->setCellValue($sumCols[$i] . $row, $h);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summarySheet->getStyle("A{$row}:J{$row}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'color' => ['argb' => 'FF' . $headerFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $headerBg]],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Summary column widths
|
||||||
|
$summarySheet->getColumnDimension('B')->setWidth(18);
|
||||||
|
$summarySheet->getColumnDimension('C')->setWidth(25);
|
||||||
|
$summarySheet->getColumnDimension('D')->setWidth(35);
|
||||||
|
$summarySheet->getColumnDimension('G')->setWidth(14);
|
||||||
|
$summarySheet->getColumnDimension('I')->setWidth(14);
|
||||||
|
$summarySheet->getColumnDimension('J')->setWidth(16);
|
||||||
|
|
||||||
|
$row++;
|
||||||
|
$summaryStartRow = $row;
|
||||||
|
$globalLineCount = 0;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 2. INDIVIDUAL INVOICE SHEETS + POPULATE SUMMARY
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
foreach ($invoices as $invIdx => $inv) {
|
||||||
|
// Fetch line items for this invoice
|
||||||
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
|
||||||
|
$stmtLines->execute([$inv['id']]);
|
||||||
|
$lines = $stmtLines->fetchAll();
|
||||||
|
|
||||||
|
// --- Add to Summary Sheet ---
|
||||||
|
if (!empty($lines)) {
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$globalLineCount++;
|
||||||
|
$summarySheet->setCellValue("A{$row}", $globalLineCount);
|
||||||
|
$summarySheet->setCellValue("B{$row}", $inv['invoice_number'] ?? '-');
|
||||||
|
$summarySheet->setCellValue("C{$row}", $dec($inv['supplier_name']));
|
||||||
|
$summarySheet->setCellValue("D{$row}", $line['description'] ?? 'بدون وصف');
|
||||||
|
$summarySheet->setCellValue("E{$row}", (float)$line['quantity']);
|
||||||
|
$summarySheet->setCellValue("F{$row}", (float)$line['unit_price']);
|
||||||
|
$summarySheet->setCellValue("G{$row}", "=E{$row}*F{$row}");
|
||||||
|
$summarySheet->setCellValue("H{$row}", (float)$line['tax_rate']);
|
||||||
|
$summarySheet->setCellValue("I{$row}", "=G{$row}*H{$row}");
|
||||||
|
$summarySheet->setCellValue("J{$row}", "=G{$row}+I{$row}");
|
||||||
|
|
||||||
|
if ($globalLineCount % 2 === 0) {
|
||||||
|
$summarySheet->getStyle("A{$row}:J{$row}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FFF8F7FD');
|
||||||
|
}
|
||||||
|
$row++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if no line items
|
||||||
|
$globalLineCount++;
|
||||||
|
$summarySheet->setCellValue("A{$row}", $globalLineCount);
|
||||||
|
$summarySheet->setCellValue("B{$row}", $inv['invoice_number'] ?? '-');
|
||||||
|
$summarySheet->setCellValue("C{$row}", $dec($inv['supplier_name']));
|
||||||
|
$summarySheet->setCellValue("D{$row}", 'إجمالي الفاتورة');
|
||||||
|
$summarySheet->setCellValue("E{$row}", 1);
|
||||||
|
$summarySheet->setCellValue("F{$row}", (float)$inv['subtotal']);
|
||||||
|
$summarySheet->setCellValue("G{$row}", "=E{$row}*F{$row}");
|
||||||
|
$summarySheet->setCellValue("H{$row}", 0.16);
|
||||||
|
$summarySheet->setCellValue("I{$row}", "=G{$row}*H{$row}");
|
||||||
|
$summarySheet->setCellValue("J{$row}", "=G{$row}+I{$row}");
|
||||||
|
$row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create Individual Sheet ---
|
||||||
|
$sheet = $spreadsheet->createSheet();
|
||||||
|
$invoiceNum = $inv['invoice_number'] ?? ('INV-' . ($invIdx + 1));
|
||||||
|
$sheetTitle = mb_substr(preg_replace('/[^a-zA-Z0-9\x{0600}-\x{06FF}\s\-]/u', '', $invoiceNum), 0, 31) ?: ('فاتورة ' . ($invIdx + 1));
|
||||||
|
$sheet->setTitle($sheetTitle);
|
||||||
|
$sheet->setRightToLeft(true);
|
||||||
|
|
||||||
|
// ── Column widths ──
|
||||||
|
$sheet->getColumnDimension('A')->setWidth(6); // #
|
||||||
|
$sheet->getColumnDimension('B')->setWidth(38); // Description
|
||||||
|
$sheet->getColumnDimension('C')->setWidth(12); // Quantity
|
||||||
|
$sheet->getColumnDimension('D')->setWidth(14); // Unit Price
|
||||||
|
$sheet->getColumnDimension('E')->setWidth(16); // Subtotal (formula)
|
||||||
|
$sheet->getColumnDimension('F')->setWidth(14); // Tax Rate
|
||||||
|
$sheet->getColumnDimension('G')->setWidth(16); // Tax Amount (formula)
|
||||||
|
$sheet->getColumnDimension('H')->setWidth(14); // Discount
|
||||||
|
$sheet->getColumnDimension('I')->setWidth(18); // Net Total (formula)
|
||||||
|
|
||||||
|
$invRow = 1;
|
||||||
|
|
||||||
|
// ── INVOICE HEADER ──────────────────────────
|
||||||
|
// We use A for Logo, B:H for Title, I for QR to avoid merge issues
|
||||||
|
$sheet->setCellValue("B{$invRow}", 'مُـصَـادَق — تقرير فاتورة مشتريات');
|
||||||
|
$sheet->mergeCells("B{$invRow}:H{$invRow}");
|
||||||
|
$sheet->getStyle("B{$invRow}:H{$invRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => 16, 'color' => ['argb' => 'FF' . $headerFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $headerBg]],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||||
|
]);
|
||||||
|
$sheet->getRowDimension($invRow)->setRowHeight(45);
|
||||||
|
|
||||||
|
// Background color for side cells
|
||||||
|
$sheet->getStyle("A{$invRow}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF' . $headerBg);
|
||||||
|
$sheet->getStyle("I{$invRow}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF' . $headerBg);
|
||||||
|
|
||||||
|
// --- Add Logo ---
|
||||||
|
try {
|
||||||
|
if (file_exists($logoPath)) {
|
||||||
|
$logoInv = new Drawing();
|
||||||
|
$logoInv->setName('Musadaq Logo');
|
||||||
|
$logoInv->setPath($logoPath);
|
||||||
|
$logoInv->setHeight(38);
|
||||||
|
$logoInv->setCoordinates('A' . $invRow);
|
||||||
|
$logoInv->setOffsetX(5);
|
||||||
|
$logoInv->setOffsetY(5);
|
||||||
|
$logoInv->setWorksheet($sheet);
|
||||||
|
}
|
||||||
|
} catch(\Exception $e) { error_log('Logo Invoice Error: ' . $e->getMessage()); }
|
||||||
|
|
||||||
|
// --- Add Clickable Website Link ---
|
||||||
|
// We'll move the link slightly down or put it in I1 with the QR
|
||||||
|
$sheet->setCellValue("I" . $invRow, 'musadaq.intaleqapp.com/verify_qr');
|
||||||
|
$verifyUrl = "https://musadaq.intaleqapp.com/index.php?route=verify_qr&id=" . $inv['id'];
|
||||||
|
$sheet->getCell("I" . $invRow)->getHyperlink()->setUrl($verifyUrl);
|
||||||
|
$sheet->getStyle("I" . $invRow)->applyFromArray([
|
||||||
|
'font' => ['color' => ['argb' => 'FFFFFFFF'], 'underline' => true, 'size' => 8],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_LEFT, 'vertical' => Alignment::VERTICAL_TOP],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Add Verification QR Code ---
|
||||||
|
try {
|
||||||
|
$verifyUrl = "https://musadaq.intaleqapp.com/index.php?route=verify_qr&id=" . $inv['id'];
|
||||||
|
$qrApiUrl = "https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=" . urlencode($verifyUrl);
|
||||||
|
$qrData = $downloadUrl($qrApiUrl);
|
||||||
|
if ($qrData) {
|
||||||
|
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_inv_');
|
||||||
|
file_put_contents($tmpQr, $qrData);
|
||||||
|
$drawingQr = new Drawing();
|
||||||
|
$drawingQr->setName('Verification QR');
|
||||||
|
$drawingQr->setPath($tmpQr);
|
||||||
|
$drawingQr->setHeight(38);
|
||||||
|
$drawingQr->setCoordinates('I' . $invRow);
|
||||||
|
$drawingQr->setOffsetX(5);
|
||||||
|
$drawingQr->setOffsetY(5);
|
||||||
|
$drawingQr->setWorksheet($sheet);
|
||||||
|
}
|
||||||
|
} catch(\Exception $e) {}
|
||||||
|
|
||||||
|
$invRow++;
|
||||||
|
|
||||||
|
// Invoice meta data
|
||||||
|
$metaData = [
|
||||||
|
['رقم الفاتورة', $inv['invoice_number'] ?? '-', 'اسم المورّد', $dec($inv['supplier_name'])],
|
||||||
|
['تاريخ الفاتورة', $inv['invoice_date'] ?? '-', 'الرقم الضريبي للمورّد', $dec($inv['supplier_tin'])],
|
||||||
|
['الشركة', $dec($inv['company_name_raw'] ?? ''), 'العملة', $inv['currency_code'] ?? 'JOD'],
|
||||||
|
['نوع الفاتورة', ($inv['invoice_type'] === 'cash' ? 'نقدي' : 'آجل'), 'الحالة', match($inv['status']) {
|
||||||
|
'extracted' => 'مستخرجة',
|
||||||
|
'approved' => 'معتمدة',
|
||||||
|
'submitted' => 'مقدمة لجوفتورة',
|
||||||
|
'rejected' => 'مرفوضة',
|
||||||
|
default => $inv['status']
|
||||||
|
}],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($metaData as $meta) {
|
||||||
|
$sheet->setCellValue("A{$invRow}", $meta[0]);
|
||||||
|
$sheet->mergeCells("B{$invRow}:C{$invRow}");
|
||||||
|
$sheet->setCellValue("B{$invRow}", $meta[1]);
|
||||||
|
$sheet->setCellValue("E{$invRow}", $meta[2]);
|
||||||
|
$sheet->mergeCells("F{$invRow}:I{$invRow}");
|
||||||
|
$sheet->setCellValue("F{$invRow}", $meta[3]);
|
||||||
|
|
||||||
|
$sheet->getStyle("A{$invRow}:C{$invRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => 11, 'color' => ['argb' => 'FF' . $subHeaderFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $subHeaderBg]],
|
||||||
|
]);
|
||||||
|
$sheet->getStyle("E{$invRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => 11, 'color' => ['argb' => 'FF' . $subHeaderFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $subHeaderBg]],
|
||||||
|
]);
|
||||||
|
$sheet->getRowDimension($invRow)->setRowHeight(24);
|
||||||
|
$invRow++;
|
||||||
|
}
|
||||||
|
$invRow++;
|
||||||
|
|
||||||
|
// Items Header
|
||||||
|
$headers = ['#', 'وصف البند', 'الكمية', 'سعر الوحدة', 'المجموع الجزئي', 'نسبة الضريبة', 'قيمة الضريبة', 'الخصم', 'الصافي'];
|
||||||
|
$cols = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
|
||||||
|
foreach ($headers as $i => $h) $sheet->setCellValue($cols[$i] . $invRow, $h);
|
||||||
|
|
||||||
|
$sheet->getStyle("A{$invRow}:I{$invRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'color' => ['argb' => 'FF' . $headerFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $headerBg]],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||||
|
]);
|
||||||
|
$sheet->getRowDimension($invRow)->setRowHeight(32);
|
||||||
|
$invRow++;
|
||||||
|
$itemsStart = $invRow;
|
||||||
|
|
||||||
|
if (!empty($lines)) {
|
||||||
|
foreach ($lines as $lIdx => $line) {
|
||||||
|
$sheet->setCellValue("A{$invRow}", $lIdx + 1);
|
||||||
|
$sheet->setCellValue("B{$invRow}", $line['description'] ?? 'بدون وصف');
|
||||||
|
$sheet->setCellValue("C{$invRow}", (float)$line['quantity']);
|
||||||
|
$sheet->setCellValue("D{$invRow}", (float)$line['unit_price']);
|
||||||
|
$sheet->setCellValue("E{$invRow}", "=C{$invRow}*D{$invRow}");
|
||||||
|
$sheet->setCellValue("F{$invRow}", (float)$line['tax_rate']);
|
||||||
|
$sheet->getStyle("F{$invRow}")->getNumberFormat()->setFormatCode('0%');
|
||||||
|
$sheet->setCellValue("G{$invRow}", "=E{$invRow}*F{$invRow}");
|
||||||
|
$sheet->setCellValue("H{$invRow}", (float)$line['discount_amount']);
|
||||||
|
$sheet->setCellValue("I{$invRow}", "=E{$invRow}+G{$invRow}-H{$invRow}");
|
||||||
|
if ($lIdx % 2 === 1) $sheet->getStyle("A{$invRow}:I{$invRow}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FFF8F7FD');
|
||||||
|
foreach (['D','E','G','H','I'] as $c) $sheet->getStyle("{$c}{$invRow}")->getNumberFormat()->setFormatCode('#,##0.000');
|
||||||
|
$invRow++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sheet->setCellValue("A{$invRow}", 1);
|
||||||
|
$sheet->setCellValue("B{$invRow}", 'إجمالي الفاتورة');
|
||||||
|
$sheet->setCellValue("C{$invRow}", 1);
|
||||||
|
$sheet->setCellValue("D{$invRow}", (float)$inv['subtotal']);
|
||||||
|
$sheet->setCellValue("E{$invRow}", "=C{$invRow}*D{$invRow}");
|
||||||
|
$sheet->setCellValue("F{$invRow}", 0.16);
|
||||||
|
$sheet->getStyle("F{$invRow}")->getNumberFormat()->setFormatCode('0%');
|
||||||
|
$sheet->setCellValue("G{$invRow}", "=E{$invRow}*F{$invRow}");
|
||||||
|
$sheet->setCellValue("H{$invRow}", (float)$inv['discount_total']);
|
||||||
|
$sheet->setCellValue("I{$invRow}", "=E{$invRow}+G{$invRow}-H{$invRow}");
|
||||||
|
foreach (['D','E','G','H','I'] as $c) $sheet->getStyle("{$c}{$invRow}")->getNumberFormat()->setFormatCode('#,##0.000');
|
||||||
|
$invRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row for individual sheet
|
||||||
|
$lastItemRow = $invRow - 1;
|
||||||
|
$sheet->mergeCells("A{$invRow}:B{$invRow}");
|
||||||
|
$sheet->setCellValue("A{$invRow}", 'المجموع الكلي');
|
||||||
|
$sheet->setCellValue("C{$invRow}", "=SUM(C{$itemsStart}:C{$lastItemRow})");
|
||||||
|
$sheet->setCellValue("E{$invRow}", "=SUM(E{$itemsStart}:E{$lastItemRow})");
|
||||||
|
$sheet->setCellValue("G{$invRow}", "=SUM(G{$itemsStart}:G{$lastItemRow})");
|
||||||
|
$sheet->setCellValue("H{$invRow}", "=SUM(H{$itemsStart}:H{$lastItemRow})");
|
||||||
|
$sheet->setCellValue("I{$invRow}", "=SUM(I{$itemsStart}:I{$lastItemRow})");
|
||||||
|
$sheet->getStyle("G{$invRow}:I{$invRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'color' => ['argb' => 'FF' . $totalFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $totalBg]],
|
||||||
|
]);
|
||||||
|
foreach (['C','E','G','H','I'] as $c) $sheet->getStyle("{$c}{$invRow}")->getNumberFormat()->setFormatCode('#,##0.000');
|
||||||
|
$invRow += 2;
|
||||||
|
$sheet->setCellValue("A{$invRow}", 'تم إنشاء هذا التقرير تلقائياً من منصة مُصادَق — ' . date('Y-m-d H:i'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final Summary Row with totals
|
||||||
|
$lastSummaryRow = $row - 1;
|
||||||
|
$summarySheet->mergeCells("A{$row}:D{$row}");
|
||||||
|
$summarySheet->setCellValue("A{$row}", 'المجموع الكلي النهائي');
|
||||||
|
$summarySheet->setCellValue("G{$row}", "=SUM(G{$summaryStartRow}:G{$lastSummaryRow})");
|
||||||
|
$summarySheet->setCellValue("I{$row}", "=SUM(I{$summaryStartRow}:I{$lastSummaryRow})");
|
||||||
|
$summarySheet->setCellValue("J{$row}", "=SUM(J{$summaryStartRow}:J{$lastSummaryRow})");
|
||||||
|
|
||||||
|
$summarySheet->getStyle("A{$row}:J{$row}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => 13, 'color' => ['argb' => 'FF' . $totalFont]],
|
||||||
|
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF' . $totalBg]],
|
||||||
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (['F', 'G', 'I', 'J'] as $c) {
|
||||||
|
$summarySheet->getStyle("{$c}{$summaryStartRow}:{$c}{$row}")->getNumberFormat()->setFormatCode('#,##0.000');
|
||||||
|
}
|
||||||
|
$summarySheet->getStyle("H{$summaryStartRow}:H{$row}")->getNumberFormat()->setFormatCode('0%');
|
||||||
|
|
||||||
|
// Set first sheet as active
|
||||||
|
$spreadsheet->setActiveSheetIndex(0);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// SEND FILE
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
$filename = 'musadaq_invoices_' . date('Y-m-d_His') . '.xlsx';
|
||||||
|
|
||||||
|
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Cache-Control: max-age=0');
|
||||||
|
header('Pragma: public');
|
||||||
|
|
||||||
|
$writer = new Xlsx($spreadsheet);
|
||||||
|
$writer->save('php://output');
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (ob_get_length()) ob_end_clean();
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
file_put_contents(STORAGE_PATH . '/logs/export_errors.log', "[" . date('Y-m-d H:i:s') . "] " . $e->getMessage() . "\n" . $e->getTraceAsString(), FILE_APPEND);
|
||||||
|
die("خطأ في التصدير: " . $e->getMessage());
|
||||||
|
}
|
||||||
69
app/modules_app/invoices/file.php
Normal file
69
app/modules_app/invoices/file.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Secure File Proxy for Invoices
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// Helper to output error as an image for debugging
|
||||||
|
function outputErrorImage($message) {
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
$im = imagecreatetruecolor(400, 100);
|
||||||
|
$bg = imagecolorallocate($im, 20, 20, 20);
|
||||||
|
$tc = imagecolorallocate($im, 255, 50, 50);
|
||||||
|
imagefilledrectangle($im, 0, 0, 400, 100, $bg);
|
||||||
|
imagestring($im, 5, 10, 40, $message, $tc);
|
||||||
|
imagepng($im);
|
||||||
|
imagedestroy($im);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token from header OR query string using helper
|
||||||
|
$token = input('token');
|
||||||
|
if (!$token) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$token) outputErrorImage('Forbidden: No token');
|
||||||
|
|
||||||
|
$decoded = \App\Core\JWT::decode($token, env('JWT_SECRET', ''));
|
||||||
|
if (!$decoded) outputErrorImage('Forbidden: Invalid token');
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$id = input('id');
|
||||||
|
if (!$id) outputErrorImage('Forbidden: No ID');
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT tenant_id, original_file_path FROM invoices WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) outputErrorImage('Error: Invoice not found');
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
|
||||||
|
outputErrorImage('Error: Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $invoice['original_file_path'];
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
outputErrorImage('Error: File missing on disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($filePath)) {
|
||||||
|
outputErrorImage('Error: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = mime_content_type($filePath);
|
||||||
|
if (!$mime) $mime = 'application/octet-stream';
|
||||||
|
|
||||||
|
header("Content-Type: $mime");
|
||||||
|
header("Content-Length: " . filesize($filePath));
|
||||||
|
header("Cache-Control: public, max-age=3600");
|
||||||
|
readfile($filePath);
|
||||||
|
exit;
|
||||||
109
app/modules_app/invoices/index.php
Normal file
109
app/modules_app/invoices/index.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pagination = paginate_params(25, 100);
|
||||||
|
|
||||||
|
// 2. Build WHERE clause based on Role
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($role === 'super_admin') {
|
||||||
|
$where = '1=1';
|
||||||
|
} elseif ($role === 'admin') {
|
||||||
|
$where = 'i.tenant_id = ?';
|
||||||
|
$params = [$tenantId];
|
||||||
|
} else {
|
||||||
|
// Accountant/Viewer: Filter by assigned companies
|
||||||
|
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
||||||
|
$stmtUser->execute([$userId]);
|
||||||
|
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($assignedCompanyIds)) {
|
||||||
|
json_paginated([], 0, $pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
||||||
|
$where = "i.company_id IN ($placeholders)";
|
||||||
|
$params = $assignedCompanyIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional filters from query string
|
||||||
|
$companyFilter = $_GET['company_id'] ?? null;
|
||||||
|
$statusFilter = $_GET['status'] ?? null;
|
||||||
|
$searchFilter = $_GET['search'] ?? null;
|
||||||
|
|
||||||
|
if ($companyFilter) {
|
||||||
|
$where .= ' AND i.company_id = ?';
|
||||||
|
$params[] = $companyFilter;
|
||||||
|
}
|
||||||
|
if ($statusFilter) {
|
||||||
|
$where .= ' AND i.status = ?';
|
||||||
|
$params[] = $statusFilter;
|
||||||
|
}
|
||||||
|
if ($searchFilter) {
|
||||||
|
$where .= ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ?)';
|
||||||
|
$params[] = "%$searchFilter%";
|
||||||
|
$params[] = "%$searchFilter%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Count total
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM invoices i WHERE $where");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 4. Fetch page
|
||||||
|
$joinTenant = ($role === 'super_admin') ? 'LEFT JOIN tenants t ON i.tenant_id = t.id' : '';
|
||||||
|
$selectTenant = ($role === 'super_admin') ? ', t.name as tenant_name' : '';
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*{$selectTenant}, c.name as company_name
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN companies c ON i.company_id = c.id
|
||||||
|
{$joinTenant}
|
||||||
|
WHERE {$where}
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// 5. Decrypt sensitive fields
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($invoices as &$inv) {
|
||||||
|
$inv['supplier_name'] = $dec($inv['supplier_name']);
|
||||||
|
$inv['supplier_tin'] = $dec($inv['supplier_tin']);
|
||||||
|
$inv['buyer_name'] = $dec($inv['buyer_name']);
|
||||||
|
|
||||||
|
if (!empty($inv['company_name'])) {
|
||||||
|
$inv['company_name'] = $dec($inv['company_name']);
|
||||||
|
}
|
||||||
|
if (!empty($inv['tenant_name'])) {
|
||||||
|
$inv['tenant_name'] = $dec($inv['tenant_name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_paginated($invoices, $total, $pagination);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'invoices/index');
|
||||||
|
}
|
||||||
48
app/modules_app/invoices/reject.php
Normal file
48
app/modules_app/invoices/reject.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Reject Invoice
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
json_error('Invoice ID is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) json_error('Invoice not found', 404);
|
||||||
|
if ($invoice['status'] === 'approved') json_error('لا يمكن رفض فاتورة معتمدة', 400);
|
||||||
|
|
||||||
|
$updateStmt = $db->prepare("UPDATE invoices SET status = 'rejected', updated_at = NOW() WHERE id = ?");
|
||||||
|
$updateStmt->execute([$id]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.rejected', 'invoice', $id, [
|
||||||
|
'old_status' => $invoice['status'],
|
||||||
|
], [
|
||||||
|
'new_status' => 'rejected',
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم رفض الفاتورة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Invoice Reject Error: " . $e->getMessage());
|
||||||
|
json_error('فشل في رفض الفاتورة', 500);
|
||||||
|
}
|
||||||
166
app/modules_app/invoices/submit_jofotara.php
Normal file
166
app/modules_app/invoices/submit_jofotara.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Submit Invoice to JoFotara (Jordan E-Invoicing)
|
||||||
|
* POST /v1/invoices/submit-jofotara
|
||||||
|
*
|
||||||
|
* Generates UBL 2.1 XML, submits to JoFotara API, and records the result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Core\JoFotara;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
RoleMiddleware::require(['admin', 'super_admin', 'accountant']);
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$invoiceId = $data['invoice_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$invoiceId) {
|
||||||
|
json_error('معرّف الفاتورة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 1. Fetch Invoice
|
||||||
|
$query = $role === 'super_admin'
|
||||||
|
? "SELECT i.*, c.name as company_name, c.tax_identification_number, c.jofotara_client_id_encrypted, c.jofotara_secret_key_encrypted, c.address as company_address
|
||||||
|
FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.id = ?"
|
||||||
|
: "SELECT i.*, c.name as company_name, c.tax_identification_number, c.jofotara_client_id_encrypted, c.jofotara_secret_key_encrypted, c.address as company_address
|
||||||
|
FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.id = ? AND i.tenant_id = ?";
|
||||||
|
|
||||||
|
$params = $role === 'super_admin' ? [$invoiceId] : [$invoiceId, $tenantId];
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
json_error('الفاتورة غير موجودة أو ليس لديك صلاحية', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invoice['status'] !== 'approved') {
|
||||||
|
json_error('يجب اعتماد الفاتورة أولاً قبل إرسالها لجوفتورة', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if already submitted
|
||||||
|
$stmtCheck = $db->prepare("SELECT id FROM jofotara_submissions WHERE invoice_id = ? AND status = 'accepted'");
|
||||||
|
$stmtCheck->execute([$invoiceId]);
|
||||||
|
if ($stmtCheck->fetch()) {
|
||||||
|
json_error('تم إرسال هذه الفاتورة لجوفتورة مسبقاً', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify JoFotara credentials
|
||||||
|
$clientId = Encryption::decrypt($invoice['jofotara_client_id_encrypted'] ?? '') ?: '';
|
||||||
|
$secretKey = Encryption::decrypt($invoice['jofotara_secret_key_encrypted'] ?? '') ?: '';
|
||||||
|
|
||||||
|
if (empty($clientId) || empty($secretKey)) {
|
||||||
|
json_error('يجب ربط الشركة بمنظومة جوفتورة أولاً (Client ID + Secret Key)', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Decrypt sensitive fields for XML generation
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare invoice data for XML
|
||||||
|
$invoiceData = [
|
||||||
|
'invoice_number' => $invoice['invoice_number'],
|
||||||
|
'invoice_date' => $invoice['invoice_date'],
|
||||||
|
'invoice_type' => $invoice['invoice_type'],
|
||||||
|
'invoice_category' => $invoice['invoice_category'] ?? 'simplified',
|
||||||
|
'ubl_type_code' => $invoice['ubl_type_code'] ?? '388',
|
||||||
|
'payment_method_code' => $invoice['payment_method_code'] ?? '013',
|
||||||
|
'buyer_name' => $dec($invoice['buyer_name']),
|
||||||
|
'buyer_tin' => $dec($invoice['buyer_tin']),
|
||||||
|
'buyer_national_id' => $dec($invoice['buyer_national_id']),
|
||||||
|
'subtotal' => (float)$invoice['subtotal'],
|
||||||
|
'tax_amount' => (float)$invoice['tax_amount'],
|
||||||
|
'discount_total' => (float)$invoice['discount_total'],
|
||||||
|
'grand_total' => (float)$invoice['grand_total'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch line items
|
||||||
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
|
||||||
|
$stmtLines->execute([$invoiceId]);
|
||||||
|
$invoiceData['items'] = $stmtLines->fetchAll();
|
||||||
|
|
||||||
|
$companyData = [
|
||||||
|
'name' => $invoice['company_name'],
|
||||||
|
'tax_identification_number' => $invoice['tax_identification_number'],
|
||||||
|
'address' => $invoice['company_address'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 5. Generate XML
|
||||||
|
$jofotara = new JoFotara();
|
||||||
|
$xml = $jofotara->generateXML($invoiceData, $companyData);
|
||||||
|
|
||||||
|
// 6. Submit to JoFotara API
|
||||||
|
$result = $jofotara->submitInvoice($xml, $clientId, $secretKey);
|
||||||
|
|
||||||
|
// 7. Record submission
|
||||||
|
$submissionId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO jofotara_submissions (id, invoice_id, tenant_id, company_id, jofotara_uuid, xml_content, status, qr_code_raw, response_body, submitted_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$submissionId,
|
||||||
|
$invoiceId,
|
||||||
|
$tenantId,
|
||||||
|
$invoice['company_id'],
|
||||||
|
$result['uuid'] ?? null,
|
||||||
|
$xml,
|
||||||
|
$result['success'] ? 'accepted' : 'rejected',
|
||||||
|
$result['qrCode'] ?? null,
|
||||||
|
json_encode($result['raw'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 8. Update invoice status if accepted
|
||||||
|
if ($result['success']) {
|
||||||
|
$db->prepare("UPDATE invoices SET status = 'submitted', jofotara_uuid = ? WHERE id = ?")
|
||||||
|
->execute([$result['uuid'], $invoiceId]);
|
||||||
|
|
||||||
|
// Generate local QR code
|
||||||
|
$qrBase64 = $jofotara->generateQRCode([
|
||||||
|
'supplier_name' => $companyData['name'],
|
||||||
|
'supplier_tin' => $companyData['tax_identification_number'],
|
||||||
|
'invoice_date' => $invoiceData['invoice_date'],
|
||||||
|
'grand_total' => $invoiceData['grand_total'],
|
||||||
|
'tax_amount' => $invoiceData['tax_amount'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->prepare("UPDATE invoices SET qr_code = ? WHERE id = ?")->execute([$qrBase64, $invoiceId]);
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.submitted_jofotara', 'invoice', $invoiceId, null, [
|
||||||
|
'jofotara_uuid' => $result['uuid'],
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
\App\Services\SmartNotifications::jofotaraSuccess($tenantId, $userId, $invoiceId, $result['uuid']);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'uuid' => $result['uuid'],
|
||||||
|
'qr_code' => $qrBase64,
|
||||||
|
'status' => 'accepted',
|
||||||
|
], 'تم إرسال الفاتورة لجوفتورة بنجاح');
|
||||||
|
} else {
|
||||||
|
AuditLogger::log('invoice.jofotara_rejected', 'invoice', $invoiceId, null, [
|
||||||
|
'error' => $result['error'] ?? 'Unknown',
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
\App\Services\SmartNotifications::jofotaraRejected($tenantId, $userId, $invoiceId, $result['error'] ?? 'خطأ غير محدد');
|
||||||
|
|
||||||
|
json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422);
|
||||||
|
}
|
||||||
116
app/modules_app/invoices/update.php
Normal file
116
app/modules_app/invoices/update.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Update Invoice (Before Approval Only)
|
||||||
|
* POST /v1/invoices/update
|
||||||
|
* Allows editing extracted data before final approval.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$data = input();
|
||||||
|
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) json_error('معرّف الفاتورة مطلوب', 422);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
// 1. Fetch & verify access
|
||||||
|
$query = $role === 'super_admin'
|
||||||
|
? "SELECT * FROM invoices WHERE id = ?"
|
||||||
|
: "SELECT * FROM invoices WHERE id = ? AND tenant_id = ?";
|
||||||
|
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) json_error('الفاتورة غير موجودة', 404);
|
||||||
|
|
||||||
|
// 2. Only allow editing extracted (not yet approved) invoices
|
||||||
|
if (!in_array($invoice['status'], ['extracted', 'pending'])) {
|
||||||
|
json_error('لا يمكن تعديل الفاتورة بعد اعتمادها', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
// 3. Update main invoice fields
|
||||||
|
$fields = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
$plainFields = ['invoice_number', 'invoice_date', 'invoice_type', 'invoice_category',
|
||||||
|
'subtotal', 'tax_amount', 'discount_total', 'grand_total', 'currency_code'];
|
||||||
|
|
||||||
|
foreach ($plainFields as $f) {
|
||||||
|
if (isset($data[$f])) {
|
||||||
|
$fields[] = "$f = ?";
|
||||||
|
$values[] = $data[$f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypted fields
|
||||||
|
$encryptedFields = [
|
||||||
|
'supplier_name' => 'supplier_name',
|
||||||
|
'supplier_tin' => 'supplier_tin',
|
||||||
|
'supplier_address' => 'supplier_address',
|
||||||
|
'buyer_name' => 'buyer_name',
|
||||||
|
'buyer_tin' => 'buyer_tin',
|
||||||
|
'buyer_national_id' => 'buyer_national_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($encryptedFields as $key => $column) {
|
||||||
|
if (isset($data[$key])) {
|
||||||
|
$fields[] = "$column = ?";
|
||||||
|
$values[] = !empty($data[$key]) ? Encryption::encrypt($data[$key]) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields)) {
|
||||||
|
$fields[] = 'updated_at = NOW()';
|
||||||
|
$values[] = $id;
|
||||||
|
$sql = "UPDATE invoices SET " . implode(', ', $fields) . " WHERE id = ?";
|
||||||
|
$db->prepare($sql)->execute($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update line items (if provided)
|
||||||
|
if (isset($data['items']) && is_array($data['items'])) {
|
||||||
|
// Delete old lines
|
||||||
|
$db->prepare("DELETE FROM invoice_lines WHERE invoice_id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
// Insert new lines
|
||||||
|
$lineStmt = $db->prepare(
|
||||||
|
"INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($data['items'] as $idx => $item) {
|
||||||
|
$lineStmt->execute([
|
||||||
|
Database::generateUuid(),
|
||||||
|
$id,
|
||||||
|
$item['line_number'] ?? ($idx + 1),
|
||||||
|
$item['description'] ?? '',
|
||||||
|
$item['quantity'] ?? 1,
|
||||||
|
$item['unit_price'] ?? 0,
|
||||||
|
$item['tax_rate'] ?? 0,
|
||||||
|
$item['line_total'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
AuditLogger::log('invoice.updated', 'invoice', $id, null, [
|
||||||
|
'fields_updated' => array_keys($data),
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم تحديث بيانات الفاتورة بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
error_log("Invoice Update Error: " . $e->getMessage());
|
||||||
|
safe_error($e, 'invoices/update', 'فشل تحديث الفاتورة.');
|
||||||
|
}
|
||||||
257
app/modules_app/invoices/upload.php
Normal file
257
app/modules_app/invoices/upload.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Invoice Upload Endpoint (Multi-Tenant & Role-Aware)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// تفعيل إظهار الأخطاء برمجياً لهذه الصفحة فقط لضمان عدم وجود فشل صامت
|
||||||
|
ini_set('display_errors', 0); // اجعلها 1 مؤقتاً إذا استمرت المشكلة لمعرفة الخطأ من السيرفر
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Auth Check
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
$allowedRoles = ['super_admin', 'admin', 'accountant', 'employee'];
|
||||||
|
if (!in_array($decoded['role'], $allowedRoles)) {
|
||||||
|
json_error('غير مصرح لك برفع الفواتير', 403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUOTA CHECK (skip for super_admin ONLY) ---
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
QuotaMiddleware::checkInvoiceQuota($tenantId);
|
||||||
|
}
|
||||||
|
// -------------------
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// 2. Validate Request
|
||||||
|
// استخدام $_POST للتعامل الآمن مع multipart/form-data
|
||||||
|
$companyId = $_POST['company_id'] ?? null;
|
||||||
|
if (!$companyId && function_exists('input')) {
|
||||||
|
$data = input();
|
||||||
|
$companyId = $data['company_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$companyId || !isset($_FILES['invoice']) || $_FILES['invoice']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$uploadError = $_FILES['invoice']['error'] ?? 'No File';
|
||||||
|
json_error('رقم الشركة وملف الفاتورة مطلوبان، أو حدث خطأ أثناء الرفع (كود الخطأ: ' . $uploadError . ')', 422);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Permission Check
|
||||||
|
if ($decoded['role'] === 'super_admin') {
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
|
||||||
|
$stmt->execute([$companyId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$stmt->execute([$companyId, $tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// لضمان حفظ الفاتورة في المكتب الصحيح إذا كان المرفوع سوبر أدمن
|
||||||
|
if ($decoded['role'] === 'super_admin') {
|
||||||
|
$tenantId = $company['tenant_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle File Upload
|
||||||
|
$tenantDir = STORAGE_PATH . '/invoices/' . $tenantId;
|
||||||
|
$companyDir = $tenantDir . '/' . $companyId;
|
||||||
|
$dateFolder = date('Y-m-d');
|
||||||
|
$uploadDir = $companyDir . '/' . $dateFolder . '/';
|
||||||
|
|
||||||
|
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
if (!mkdir($dir, 0755, true)) {
|
||||||
|
error_log('Failed to create storage directory: ' . $dir);
|
||||||
|
json_error('فشل في تجهيز مساحة التخزين', 500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
chmod($dir, 0755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = pathinfo($_FILES['invoice']['name'], PATHINFO_EXTENSION);
|
||||||
|
$fileName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $extension;
|
||||||
|
$targetFile = $uploadDir . $fileName;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) {
|
||||||
|
json_error('فشل في نقل الملف المرفوع إلى مسار التخزين', 500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Run AI Extraction
|
||||||
|
$mimeType = $_FILES['invoice']['type'];
|
||||||
|
$fileContent = file_get_contents($targetFile);
|
||||||
|
$base64Data = base64_encode($fileContent);
|
||||||
|
|
||||||
|
$extracted = AI::extractInvoiceData($base64Data, $mimeType);
|
||||||
|
|
||||||
|
if (!$extracted) {
|
||||||
|
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoices (
|
||||||
|
id, tenant_id, company_id, uploaded_by, original_file_path, status, created_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 'uploaded', NOW()
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$stmt->execute([$invoiceId, $tenantId, $companyId, $userId, $targetFile]);
|
||||||
|
json_success(['id' => $invoiceId], 'تم رفع الفاتورة ولكن فشل استخراج البيانات تلقائياً');
|
||||||
|
exit; // إيقاف التنفيذ إلزامي هنا لمنع الانهيار
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Save Extracted Data
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$extractedInvoices = $extracted['invoices'] ?? [$extracted];
|
||||||
|
$savedIds = [];
|
||||||
|
|
||||||
|
foreach ($extractedInvoices as $inv) {
|
||||||
|
$supplierTin = $inv['supplier']['tin'] ?? '';
|
||||||
|
$invoiceNum = $inv['invoice_number'] ?? '';
|
||||||
|
$invoiceDate = $inv['invoice_date'] ?? '';
|
||||||
|
|
||||||
|
$invoiceHash = null;
|
||||||
|
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
|
||||||
|
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
|
||||||
|
$invoiceHash = hash('sha256', strtolower($rawHashString));
|
||||||
|
|
||||||
|
$checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL");
|
||||||
|
$checkStmt->execute([$companyId, $invoiceHash]);
|
||||||
|
if ($checkStmt->fetch()) {
|
||||||
|
continue; // Skip duplicates in multi-page files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
|
||||||
|
|
||||||
|
$subtotal = is_numeric($inv['subtotal'] ?? null) ? $inv['subtotal'] : 0;
|
||||||
|
$tax = is_numeric($inv['tax_amount'] ?? null) ? $inv['tax_amount'] : 0;
|
||||||
|
$disc = is_numeric($inv['discount_total'] ?? null) ? $inv['discount_total'] : 0;
|
||||||
|
$total = is_numeric($inv['grand_total'] ?? null) ? $inv['grand_total'] : 0;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO invoices (
|
||||||
|
id, tenant_id, company_id, uploaded_by, original_file_path, status,
|
||||||
|
invoice_number, invoice_date, invoice_type, invoice_category,
|
||||||
|
supplier_tin, supplier_name, supplier_address,
|
||||||
|
buyer_tin, buyer_name, buyer_national_id,
|
||||||
|
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
||||||
|
invoice_hash, validation_warnings,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted',
|
||||||
|
:num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid,
|
||||||
|
:sub, :tax, :disc, :total, :cur,
|
||||||
|
:hash, :warnings,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $invoiceId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'uploaded_by' => $userId,
|
||||||
|
'path' => $targetFile,
|
||||||
|
'num' => !empty($invoiceNum) ? $invoiceNum : null,
|
||||||
|
'date' => $validDate,
|
||||||
|
'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash',
|
||||||
|
'cat' => !empty($inv['invoice_category']) ? $inv['invoice_category'] : 'simplified',
|
||||||
|
's_tin' => Encryption::encrypt($supplierTin),
|
||||||
|
's_name' => Encryption::encrypt($inv['supplier']['name'] ?? ''),
|
||||||
|
's_addr' => Encryption::encrypt($inv['supplier']['address'] ?? ''),
|
||||||
|
'b_tin' => Encryption::encrypt($inv['buyer']['tin'] ?? ''),
|
||||||
|
'b_name' => Encryption::encrypt($inv['buyer']['name'] ?? ''),
|
||||||
|
'b_nid' => Encryption::encrypt($inv['buyer']['national_id'] ?? ''),
|
||||||
|
'sub' => $subtotal,
|
||||||
|
'tax' => $tax,
|
||||||
|
'disc' => $disc,
|
||||||
|
'total' => $total,
|
||||||
|
'cur' => !empty($inv['currency_code']) ? $inv['currency_code'] : 'JOD',
|
||||||
|
'hash' => $invoiceHash,
|
||||||
|
'warnings' => !empty($inv['validation_warnings']) ? json_encode($inv['validation_warnings']) : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Save Line Items
|
||||||
|
if (!empty($inv['lines']) && is_array($inv['lines'])) {
|
||||||
|
$lineStmt = $db->prepare("
|
||||||
|
INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
foreach ($inv['lines'] as $index => $item) {
|
||||||
|
$lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
$lineStmt->execute([
|
||||||
|
$lineId,
|
||||||
|
$invoiceId,
|
||||||
|
$item['line_number'] ?? ($index + 1),
|
||||||
|
$item['description'] ?? 'بدون وصف',
|
||||||
|
is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1,
|
||||||
|
is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0,
|
||||||
|
is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16,
|
||||||
|
is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$savedIds[] = $invoiceId;
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
if (empty($savedIds)) {
|
||||||
|
json_error('لم يتم حفظ أي فواتير جديدة من هذا الملف (قد تكون مكررة)', 409);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NOTIFICATIONS & GAMIFICATION (for first invoice only for simplicity) ---
|
||||||
|
\App\Services\SmartNotifications::checkQuotaWarning($tenantId);
|
||||||
|
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
|
||||||
|
// -----------------------
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'ids' => $savedIds,
|
||||||
|
'message' => 'تم استخراج وحفظ ' . count($savedIds) . ' فواتير من الملف بنجاح'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Backward compatibility for Flutter (expecting a single 'id')
|
||||||
|
if (count($savedIds) === 1) {
|
||||||
|
$response['id'] = $savedIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($response);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if (isset($db) && $db->inTransaction()) {
|
||||||
|
$db->rollBack();
|
||||||
|
}
|
||||||
|
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
|
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
|
exit;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if (isset($db) && $db->inTransaction()) {
|
||||||
|
$db->rollBack();
|
||||||
|
}
|
||||||
|
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
|
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
191
app/modules_app/invoices/verify_public.php
Normal file
191
app/modules_app/invoices/verify_public.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
// Minimal public verification
|
||||||
|
if (!defined('ROOT_PATH')) define('ROOT_PATH', realpath(dirname(__DIR__, 2)));
|
||||||
|
|
||||||
|
// Load Env manually
|
||||||
|
$envFile = '/home/intaleqapp-musadaq/env/.env';
|
||||||
|
if (!file_exists($envFile)) $envFile = ROOT_PATH . '/.env';
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (str_starts_with(trim($line), '#')) continue;
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
$n = trim($parts[0]); $v = trim($parts[1], " \t\n\r\0\x0B\"'");
|
||||||
|
$_ENV[$n] = $v; $_SERVER[$n] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
|
||||||
|
header_remove("Content-Security-Policy");
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$invoiceId = $_GET['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$invoiceId) {
|
||||||
|
die("<h1>رابط التحقق غير صالح</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Fetch invoice with company and supplier details
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*, c.name as company_name_raw
|
||||||
|
FROM invoices i
|
||||||
|
JOIN companies c ON i.company_id = c.id
|
||||||
|
WHERE i.id = ? AND i.deleted_at IS NULL
|
||||||
|
");
|
||||||
|
$stmt->execute([$invoiceId]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
die("<h1>الفاتورة غير موجودة أو تم حذفها</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt helper
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '-';
|
||||||
|
$result = Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
$supplierName = $dec($invoice['supplier_name']);
|
||||||
|
$companyName = $dec($invoice['company_name_raw']);
|
||||||
|
$total = number_format((float)$invoice['grand_total'], 3);
|
||||||
|
$date = $invoice['invoice_date'] ?: 'غير محدد';
|
||||||
|
$status = match($invoice['status']) {
|
||||||
|
'extracted' => 'مستخرجة',
|
||||||
|
'approved' => 'معتمدة ✅',
|
||||||
|
'submitted' => 'مقدمة للضريبة 🏛️',
|
||||||
|
'rejected' => 'مرفوضة ❌',
|
||||||
|
default => 'قيد المعالجة'
|
||||||
|
};
|
||||||
|
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>التحقق من الفاتورة - مُصادَق</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Tajawal:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1C1550;
|
||||||
|
--accent: #00D1B2;
|
||||||
|
--bg: #F8F9FA;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Tajawal', sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.verify-card {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||||
|
max-width: 450px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: #E9ECEF;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: right;
|
||||||
|
border-top: 1px solid #EEE;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.info-item label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.info-item span {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #AAA;
|
||||||
|
}
|
||||||
|
.btn-home {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="verify-card">
|
||||||
|
<div class="logo">مُـصَـادَق</div>
|
||||||
|
<div class="status-badge"><?php echo $status; ?></div>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<label>اسم المكتب (الشركة)</label>
|
||||||
|
<span><?php echo htmlspecialchars($companyName); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>اسم المورّد</label>
|
||||||
|
<span><?php echo htmlspecialchars($supplierName); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>رقم الفاتورة</label>
|
||||||
|
<span><?php echo htmlspecialchars($invoice['invoice_number'] ?: '-'); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>تاريخ الفاتورة</label>
|
||||||
|
<span><?php echo htmlspecialchars($date); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>المبلغ الإجمالي</label>
|
||||||
|
<span style="font-size: 24px; color: var(--accent);"><?php echo $total; ?> JOD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
تم التحقق من هذه الفاتورة رسمياً عبر منصة مُصادَق.<br>
|
||||||
|
<?php echo date('Y-m-d H:i:s'); ?>
|
||||||
|
</div>
|
||||||
|
<a href="https://musadaq.intaleqapp.com/" class="btn-home">زيارة منصة مُصادَق</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
die("خطأ في النظام");
|
||||||
|
}
|
||||||
110
app/modules_app/invoices/view.php
Normal file
110
app/modules_app/invoices/view.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Invoice View Endpoint (Decrypted & JoFotara Aware)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if (!$id) json_error('Invoice ID is required', 422);
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch Invoice (Super Admin sees all, others are tenant-scoped)
|
||||||
|
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.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
} 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.id = ? AND i.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$id, $tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
if (!$invoice) json_error('Invoice not found or access denied', 404);
|
||||||
|
|
||||||
|
// 2. Fetch Line Items
|
||||||
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
|
||||||
|
$stmtLines->execute([$id]);
|
||||||
|
$invoice['items'] = $stmtLines->fetchAll();
|
||||||
|
|
||||||
|
// 3. Decrypt all encrypted fields — robust: if decryption fails, keep original value
|
||||||
|
$dec = function($val) {
|
||||||
|
if (empty($val)) return '';
|
||||||
|
$result = \App\Core\Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
|
$invoice['supplier_tin'] = $dec($invoice['supplier_tin']);
|
||||||
|
$invoice['supplier_name'] = $dec($invoice['supplier_name']);
|
||||||
|
$invoice['supplier_address'] = $dec($invoice['supplier_address']);
|
||||||
|
$invoice['buyer_tin'] = $dec($invoice['buyer_tin']);
|
||||||
|
$invoice['buyer_name'] = $dec($invoice['buyer_name']);
|
||||||
|
$invoice['buyer_national_id'] = $dec($invoice['buyer_national_id']);
|
||||||
|
|
||||||
|
// company_name is stored plaintext in the companies table — no decryption needed
|
||||||
|
// $invoice['company_name'] is already plaintext from the JOIN
|
||||||
|
|
||||||
|
// 3.5 Parse Validation Warnings
|
||||||
|
if (isset($invoice['validation_warnings']) && $invoice['validation_warnings']) {
|
||||||
|
$invoice['validation_warnings'] = json_decode($invoice['validation_warnings'], true);
|
||||||
|
} else {
|
||||||
|
$invoice['validation_warnings'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch JoFotara Submission Data (latest accepted submission)
|
||||||
|
$stmtSub = $db->prepare("
|
||||||
|
SELECT jofotara_uuid, submitted_at, qr_code_raw, response_body
|
||||||
|
FROM jofotara_submissions
|
||||||
|
WHERE invoice_id = ? AND status = 'accepted'
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmtSub->execute([$id]);
|
||||||
|
$submission = $stmtSub->fetch();
|
||||||
|
|
||||||
|
if ($submission) {
|
||||||
|
$invoice['jofotara'] = [
|
||||||
|
'uuid' => $submission['jofotara_uuid'],
|
||||||
|
'submitted_at' => $submission['submitted_at'],
|
||||||
|
'qr_image_uri' => $submission['qr_code_raw']
|
||||||
|
? 'data:image/png;base64,' . $submission['qr_code_raw']
|
||||||
|
: null,
|
||||||
|
'has_xml' => true,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$invoice['jofotara'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build the secure file URL with token (for Image.network compatibility)
|
||||||
|
$authHeader = getallheaders()['Authorization'] ?? getallheaders()['authorization'] ?? '';
|
||||||
|
$token = '';
|
||||||
|
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
}
|
||||||
|
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . urlencode($id) . '&token=' . $token;
|
||||||
|
|
||||||
|
// 6. Include local QR code from invoices table if available
|
||||||
|
// (This is used as a fallback in shell.php if jofotara object is missing)
|
||||||
|
|
||||||
|
json_success($invoice);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Invoice View Error: " . $e->getMessage());
|
||||||
|
json_error('Server error during invoice retrieval', 500);
|
||||||
|
}
|
||||||
74
app/modules_app/marketplace/listings.php
Normal file
74
app/modules_app/marketplace/listings.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Marketplace — Accountant Directory & Service Listings
|
||||||
|
* GET /v1/marketplace/listings
|
||||||
|
* GET /v1/marketplace/listings?city=amman&specialty=tax
|
||||||
|
*
|
||||||
|
* Public directory where accounting offices can list their services
|
||||||
|
* and businesses can find accountants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$pagination = paginate_params(20, 50);
|
||||||
|
|
||||||
|
$city = $_GET['city'] ?? null;
|
||||||
|
$specialty = $_GET['specialty'] ?? null;
|
||||||
|
$search = $_GET['search'] ?? null;
|
||||||
|
|
||||||
|
$where = "ml.is_active = 1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($city) {
|
||||||
|
$where .= " AND ml.city = ?";
|
||||||
|
$params[] = $city;
|
||||||
|
}
|
||||||
|
if ($specialty) {
|
||||||
|
$where .= " AND ml.specialty = ?";
|
||||||
|
$params[] = $specialty;
|
||||||
|
}
|
||||||
|
if ($search) {
|
||||||
|
$where .= " AND (ml.office_name LIKE ? OR ml.description LIKE ?)";
|
||||||
|
$params[] = "%{$search}%";
|
||||||
|
$params[] = "%{$search}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Count
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM marketplace_listings ml WHERE {$where}");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Fetch
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT ml.*, t.name as tenant_name
|
||||||
|
FROM marketplace_listings ml
|
||||||
|
LEFT JOIN tenants t ON ml.tenant_id = t.id
|
||||||
|
WHERE {$where}
|
||||||
|
ORDER BY ml.is_featured DESC, ml.rating DESC, ml.created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$listings = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Decrypt names
|
||||||
|
foreach ($listings as &$l) {
|
||||||
|
if (!empty($l['tenant_name'])) {
|
||||||
|
$dec = Encryption::decrypt($l['tenant_name']);
|
||||||
|
$l['tenant_name'] = ($dec !== false && $dec !== null) ? $dec : $l['tenant_name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cities = ['amman' => 'عمّان', 'irbid' => 'إربد', 'zarqa' => 'الزرقاء', 'aqaba' => 'العقبة', 'salt' => 'السلط', 'madaba' => 'مأدبا', 'karak' => 'الكرك', 'other' => 'أخرى'];
|
||||||
|
$specialties = ['tax' => 'ضرائب', 'audit' => 'تدقيق', 'bookkeeping' => 'مسك دفاتر', 'payroll' => 'رواتب', 'consulting' => 'استشارات', 'general' => 'عام'];
|
||||||
|
|
||||||
|
json_paginated($listings, $total, $pagination, 'سوق المحاسبين');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'marketplace/listings', 'حدث خطأ في تحميل القوائم.');
|
||||||
|
}
|
||||||
63
app/modules_app/marketplace/my_listing.php
Normal file
63
app/modules_app/marketplace/my_listing.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Marketplace — Create/Update My Listing
|
||||||
|
* POST /v1/marketplace/my-listing
|
||||||
|
* Body: { "office_name": "...", "city": "amman", "specialty": "tax", "description": "...", "phone": "...", "email": "..." }
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$data = input();
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'office_name' => 'required',
|
||||||
|
'city' => 'required',
|
||||||
|
'specialty' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('بيانات ناقصة', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if listing exists
|
||||||
|
$existing = $db->prepare("SELECT id FROM marketplace_listings WHERE tenant_id = ? LIMIT 1");
|
||||||
|
$existing->execute([$tenantId]);
|
||||||
|
$row = $existing->fetch();
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
// Update
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE marketplace_listings SET
|
||||||
|
office_name = ?, city = ?, specialty = ?, description = ?,
|
||||||
|
contact_phone = ?, contact_email = ?, updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
")->execute([
|
||||||
|
$data['office_name'], $data['city'], $data['specialty'],
|
||||||
|
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '',
|
||||||
|
$tenantId
|
||||||
|
]);
|
||||||
|
json_success(['id' => $row['id']], 'تم تحديث القائمة بنجاح');
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
$id = Database::generateUuid();
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO marketplace_listings (id, tenant_id, office_name, city, specialty, description, contact_phone, contact_email, is_active, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW())
|
||||||
|
")->execute([
|
||||||
|
$id, $tenantId, $data['office_name'], $data['city'], $data['specialty'],
|
||||||
|
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? ''
|
||||||
|
]);
|
||||||
|
json_success(['id' => $id], 'تم إضافة مكتبك للسوق بنجاح! 🎉');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'marketplace/my-listing', 'حدث خطأ في حفظ القائمة.');
|
||||||
|
}
|
||||||
42
app/modules_app/notifications/index.php
Normal file
42
app/modules_app/notifications/index.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* In-App Notifications
|
||||||
|
* GET /v1/notifications
|
||||||
|
* Returns user's notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = min(50, max(10, (int)($_GET['limit'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
// Get total + unread count
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) as unread FROM notifications WHERE user_id = ?");
|
||||||
|
$countStmt->execute([$userId]);
|
||||||
|
$counts = $countStmt->fetch();
|
||||||
|
|
||||||
|
// Fetch notifications
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT * FROM notifications
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $limit, $offset]);
|
||||||
|
$notifications = $stmt->fetchAll();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => (int)($counts['unread'] ?? 0),
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'total' => (int)($counts['total'] ?? 0),
|
||||||
|
'pages' => ceil(($counts['total'] ?? 0) / $limit),
|
||||||
|
],
|
||||||
|
]);
|
||||||
28
app/modules_app/notifications/read.php
Normal file
28
app/modules_app/notifications/read.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Mark Notification(s) as Read
|
||||||
|
* POST /v1/notifications/read
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$data = input();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
$markAll = $data['mark_all'] ?? false;
|
||||||
|
|
||||||
|
if ($markAll) {
|
||||||
|
$db->prepare("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND is_read = 0")
|
||||||
|
->execute([$userId]);
|
||||||
|
json_success(null, 'تم تعليم جميع الإشعارات كمقروءة');
|
||||||
|
} elseif ($id) {
|
||||||
|
$db->prepare("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE id = ? AND user_id = ?")
|
||||||
|
->execute([$id, $userId]);
|
||||||
|
json_success(null, 'تم تعليم الإشعار كمقروء');
|
||||||
|
} else {
|
||||||
|
json_error('يرجى تحديد الإشعار', 422);
|
||||||
|
}
|
||||||
156
app/modules_app/payments/bot_webhook.php
Normal file
156
app/modules_app/payments/bot_webhook.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Bank Bot Webhook
|
||||||
|
* POST /api/v1/payments/bot-webhook
|
||||||
|
*
|
||||||
|
* Receives SMS notifications from the Android bot.
|
||||||
|
* Extracts the reference number and amount.
|
||||||
|
* Matches with pending payment requests to auto-activate subscriptions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\PaymentParser;
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
// Simple Auth for the Bot
|
||||||
|
$botToken = env('BOT_WEBHOOK_TOKEN');
|
||||||
|
$providedToken = $_SERVER['HTTP_X_BOT_TOKEN'] ?? $data['token'] ?? '';
|
||||||
|
|
||||||
|
if ($providedToken !== $botToken) {
|
||||||
|
json_error('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'raw_message' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('رسالة البنك مطلوبة.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawMessage = $data['raw_message'];
|
||||||
|
$bankReference = trim($data['bank_reference'] ?? '');
|
||||||
|
$amount = (float)($data['amount'] ?? 0);
|
||||||
|
$senderName = $data['sender_name'] ?? 'غير معروف';
|
||||||
|
|
||||||
|
// Robust Parsing (for Orange Money / CliQ Jordan)
|
||||||
|
if (empty($bankReference) || $amount <= 0) {
|
||||||
|
$bankReference = PaymentParser::extractReference($rawMessage) ?: $bankReference;
|
||||||
|
$amount = PaymentParser::extractAmount($rawMessage) ?: $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($bankReference) || $amount <= 0) {
|
||||||
|
json_error('فشل استخراج بيانات التحويل من الرسالة.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Insert into bank_transactions
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO bank_transactions (bank_reference, amount, sender_name, raw_message, is_claimed, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 0, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE raw_message = VALUES(raw_message)
|
||||||
|
");
|
||||||
|
$stmt->execute([$bankReference, $amount, $senderName, $rawMessage]);
|
||||||
|
$transactionId = $db->lastInsertId();
|
||||||
|
|
||||||
|
if (!$transactionId) {
|
||||||
|
$transactionId = $db->query("SELECT id FROM bank_transactions WHERE bank_reference = '$bankReference'")->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if there is a pending payment request waiting for this reference
|
||||||
|
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE bank_reference = ? AND status IN ('pending', 'uploaded')");
|
||||||
|
$stmt->execute([$bankReference]);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
$message = 'تم استلام وتخزين الحوالة البنكية.';
|
||||||
|
|
||||||
|
if ($payment) {
|
||||||
|
// Match found! Check amount
|
||||||
|
$expectedAmount = (float)$payment['amount_jod'];
|
||||||
|
|
||||||
|
if (abs($expectedAmount - $amount) < 0.01) {
|
||||||
|
// Amount matches exactly -> Auto Approve
|
||||||
|
activateSubscription($db, $payment, $payment['user_id']);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$payment['id']]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$transactionId]);
|
||||||
|
|
||||||
|
$message = 'تم استلام الحوالة ومطابقتها وتفعيل الاشتراك بنجاح.';
|
||||||
|
} else {
|
||||||
|
// Amount mismatch -> Needs manual review
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET admin_notes = 'تم وصول الحوالة ولكن المبلغ غير متطابق' WHERE id = ?");
|
||||||
|
$stmt->execute([$payment['id']]);
|
||||||
|
$message = 'تم استلام الحوالة، لكن المبلغ لم يتطابق مع الطلب.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
json_success(['status' => 'received'], $message);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Bot Webhook Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء معالجة رسالة البوت.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-activate subscription upon verified payment
|
||||||
|
*/
|
||||||
|
function activateSubscription(\PDO $db, array $payment, string $userId): void
|
||||||
|
{
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$payment['plan_id']]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$plan) return;
|
||||||
|
|
||||||
|
$startDate = date('Y-m-d H:i:s');
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
|
||||||
|
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
plan_id = VALUES(plan_id),
|
||||||
|
max_companies = VALUES(max_companies),
|
||||||
|
max_invoices_per_month = VALUES(max_invoices_per_month),
|
||||||
|
max_users = VALUES(max_users),
|
||||||
|
price_jod = VALUES(price_jod),
|
||||||
|
status = 'active',
|
||||||
|
current_period_start = VALUES(current_period_start),
|
||||||
|
current_period_end = VALUES(current_period_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
't_id' => $payment['tenant_id'],
|
||||||
|
'p_id' => $plan['id'],
|
||||||
|
'max_c' => $plan['max_companies'],
|
||||||
|
'max_i' => $plan['max_invoices_month'],
|
||||||
|
'max_u' => $plan['max_users'],
|
||||||
|
'price' => $plan['price_jod'],
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Log activation
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, new_data) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$userId,
|
||||||
|
$payment['id'],
|
||||||
|
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true, 'source' => 'bot_webhook'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
116
app/modules_app/payments/create.php
Normal file
116
app/modules_app/payments/create.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Create Payment Request (Admin/Accountant)
|
||||||
|
* POST /api/v1/payments/create
|
||||||
|
*
|
||||||
|
* Creates a payment request for subscription upgrade.
|
||||||
|
* Returns CliQ alias and reference number for transfer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
// Only admin, accountant or super_admin can create payment requests
|
||||||
|
if (!in_array($decoded['role'], ['admin', 'accountant', 'super_admin'])) {
|
||||||
|
json_error('غير مصرح لك بإنشاء طلب دفع.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'plan_id' => 'required',
|
||||||
|
]);
|
||||||
|
if ($errors) {
|
||||||
|
json_error('معرف الباقة مطلوب.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$planId = $data['plan_id'];
|
||||||
|
$cycle = $data['billing_cycle'] ?? 'annual'; // Default to annual
|
||||||
|
|
||||||
|
if (!in_array($cycle, ['monthly', 'annual'])) {
|
||||||
|
json_error('دورة الفوترة غير صالحة.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get plan details
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$planId]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$plan) {
|
||||||
|
json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine amount based on cycle
|
||||||
|
$amount = ($cycle === 'monthly') ? ($plan['price_monthly_jod'] ?? $plan['price_jod']) : ($plan['price_annual_jod'] ?? ($plan['price_jod'] * 10));
|
||||||
|
|
||||||
|
// 2. Check for existing pending payment for this tenant
|
||||||
|
$stmt = $db->prepare("SELECT id FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
json_error('لديك طلب دفع قائم بالفعل. يرجى إتمامه أو إلغاؤه أولاً.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate unique reference number (MSQ-XXXXXX)
|
||||||
|
$referenceNumber = 'MSQ-' . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8));
|
||||||
|
|
||||||
|
// 4. Get CliQ alias from config
|
||||||
|
$cliqAlias = env('CLIQ_ALIAS', 'musadaq-pay');
|
||||||
|
|
||||||
|
// 5. Get payer name
|
||||||
|
$stmt = $db->prepare("SELECT name, phone FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
// 6. Create payment request
|
||||||
|
$paymentId = Database::generateUuid();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO payment_requests (id, tenant_id, user_id, plan_id, billing_cycle, amount_jod, internal_reference, cliq_alias, payer_name, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$paymentId,
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$planId,
|
||||||
|
$cycle,
|
||||||
|
$amount,
|
||||||
|
$referenceNumber,
|
||||||
|
$cliqAlias,
|
||||||
|
$user['name'] ?? ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 7. Log
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, new_data) VALUES (?, ?, 'payment.created', 'payment', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$paymentId,
|
||||||
|
json_encode(['plan_id' => $planId, 'cycle' => $cycle, 'amount' => $amount, 'ref' => $referenceNumber])
|
||||||
|
]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'payment_id' => $paymentId,
|
||||||
|
'reference_number' => $referenceNumber,
|
||||||
|
'cliq_alias' => $cliqAlias,
|
||||||
|
'amount_jod' => (float)$amount,
|
||||||
|
'plan_name' => ($plan['name_ar'] ?? $plan['name_en']) . " (" . ($cycle === 'monthly' ? 'شهري' : 'سنوي') . ")",
|
||||||
|
'payer_name' => $user['name'] ?? '',
|
||||||
|
'instructions' => "قم بالتحويل عبر CliQ إلى الاسم المستعار: {$cliqAlias} بمبلغ {$amount} دينار أردني.",
|
||||||
|
], 'تم إنشاء طلب الدفع بنجاح');
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("Payment Create Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء إنشاء طلب الدفع.', 500);
|
||||||
|
}
|
||||||
45
app/modules_app/payments/delete.php
Normal file
45
app/modules_app/payments/delete.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Delete/Cancel Payment Request (Admin/Accountant)
|
||||||
|
* POST /api/v1/payments/delete
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$paymentId = $data['payment_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$paymentId) {
|
||||||
|
json_error('معرف طلب الدفع مطلوب.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow deleting PENDING requests
|
||||||
|
$stmt = $db->prepare("SELECT id FROM payment_requests WHERE id = ? AND tenant_id = ? AND status = 'pending'");
|
||||||
|
$stmt->execute([$paymentId, $tenantId]);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
json_error('لا يمكن حذف هذا الطلب (قد يكون مقبولاً بالفعل أو غير موجود).', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("DELETE FROM payment_requests WHERE id = ? AND tenant_id = ?");
|
||||||
|
$stmt->execute([$paymentId, $tenantId]);
|
||||||
|
|
||||||
|
// Log deletion
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id) VALUES (?, ?, 'payment.deleted', 'payment', ?)");
|
||||||
|
$logStmt->execute([$tenantId, $decoded['user_id'], $paymentId]);
|
||||||
|
|
||||||
|
json_success([], 'تم إلغاء طلب الدفع بنجاح.');
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("Payment Delete Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء حذف طلب الدفع.', 500);
|
||||||
|
}
|
||||||
65
app/modules_app/payments/list.php
Normal file
65
app/modules_app/payments/list.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* List All Payment Requests (Super Admin)
|
||||||
|
* GET /api/v1/payments/list
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
json_error('هذه الصفحة لمدير النظام فقط.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$status = $_GET['status'] ?? null;
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = 20;
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status && in_array($status, ['pending', 'uploaded', 'verified', 'approved', 'rejected'])) {
|
||||||
|
$where = 'WHERE pr.status = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT pr.*,
|
||||||
|
u.name AS user_name, u.phone AS user_phone,
|
||||||
|
sp.name_ar AS plan_name_ar, sp.name_en AS plan_name_en
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN users u ON pr.user_id = u.id
|
||||||
|
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
|
||||||
|
$where
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$payments = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM payment_requests pr $where");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = $countStmt->fetch()['total'];
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'payments' => $payments,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => (int)$total,
|
||||||
|
'pages' => ceil($total / $limit)
|
||||||
|
]
|
||||||
|
], 'طلبات الدفع');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Payment List Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500);
|
||||||
|
}
|
||||||
40
app/modules_app/payments/my_requests.php
Normal file
40
app/modules_app/payments/my_requests.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* My Payment Requests (Admin/Accountant)
|
||||||
|
* GET /api/v1/payments/my-requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT pr.id, pr.plan_id, pr.amount_jod, pr.internal_reference, pr.cliq_alias,
|
||||||
|
pr.bank_reference, pr.status, pr.created_at, pr.verified_at,
|
||||||
|
sp.name_ar AS plan_name
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
|
||||||
|
WHERE pr.tenant_id = ?
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$requests = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Map internal_reference to reference_number for Flutter compatibility
|
||||||
|
foreach ($requests as &$req) {
|
||||||
|
$req['reference_number'] = $req['internal_reference'];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($requests, 'طلبات الدفع الخاصة بك');
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("My Payment Requests Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500);
|
||||||
|
}
|
||||||
123
app/modules_app/payments/review.php
Normal file
123
app/modules_app/payments/review.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Review Payment Request (Super Admin only)
|
||||||
|
* POST /api/v1/payments/review
|
||||||
|
*
|
||||||
|
* Manually approve or reject a payment request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
json_error('هذه العملية لمدير النظام فقط.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$paymentId = $data['payment_id'] ?? null;
|
||||||
|
$action = $data['action'] ?? null; // 'approve' or 'reject'
|
||||||
|
$notes = $data['notes'] ?? '';
|
||||||
|
|
||||||
|
if (!$paymentId || !in_array($action, ['approve', 'reject'])) {
|
||||||
|
json_error('معرف الطلب ونوع الإجراء (approve/reject) مطلوبان.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND status IN ('pending','uploaded','verified')");
|
||||||
|
$stmt->execute([$paymentId]);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
json_error('طلب الدفع غير موجود أو تم معالجته.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
if ($action === 'approve') {
|
||||||
|
// Activate subscription
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$payment['plan_id']]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($plan) {
|
||||||
|
$cycle = $payment['billing_cycle'] ?? 'annual';
|
||||||
|
$startDate = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if ($cycle === 'monthly') {
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
$maxInvoices = (int)$plan['max_invoices_month'];
|
||||||
|
$price = (float)($plan['price_monthly_jod'] ?? $plan['price_jod']);
|
||||||
|
} else {
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime('+1 year'));
|
||||||
|
// Annual gets 12x the monthly quota
|
||||||
|
$maxInvoices = (int)($plan['max_invoices_month'] * 12);
|
||||||
|
$price = (float)($plan['price_annual_jod'] ?? ($plan['price_jod'] * 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO subscriptions (
|
||||||
|
tenant_id, plan_id, max_companies, max_invoices_per_month, max_users,
|
||||||
|
price_jod, billing_cycle, status, current_period_start, current_period_end, updated_at
|
||||||
|
)
|
||||||
|
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, :cycle, 'active', :start, :end, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
plan_id = VALUES(plan_id),
|
||||||
|
max_companies = VALUES(max_companies),
|
||||||
|
max_invoices_per_month = VALUES(max_invoices_per_month),
|
||||||
|
max_users = VALUES(max_users),
|
||||||
|
price_jod = VALUES(price_jod),
|
||||||
|
billing_cycle = VALUES(billing_cycle),
|
||||||
|
status = 'active',
|
||||||
|
current_period_start = VALUES(current_period_start),
|
||||||
|
current_period_end = VALUES(current_period_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
't_id' => $payment['tenant_id'],
|
||||||
|
'p_id' => $plan['id'],
|
||||||
|
'max_c' => $plan['max_companies'],
|
||||||
|
'max_i' => $maxInvoices,
|
||||||
|
'max_u' => $plan['max_users'],
|
||||||
|
'price' => $price,
|
||||||
|
'cycle' => $cycle,
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', admin_notes = ?, verified_at = NOW(), updated_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$notes, $paymentId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'rejected', admin_notes = ?, updated_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$notes, $paymentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, new_data) VALUES (?, ?, ?, 'payment', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$decoded['user_id'],
|
||||||
|
"payment.{$action}d",
|
||||||
|
$paymentId,
|
||||||
|
json_encode(['notes' => $notes, 'reviewer' => $decoded['user_id']])
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'payment_id' => $paymentId,
|
||||||
|
'new_status' => $action === 'approve' ? 'approved' : 'rejected'
|
||||||
|
], $action === 'approve' ? 'تم اعتماد الدفع وتفعيل الاشتراك' : 'تم رفض طلب الدفع');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Payment Review Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء مراجعة طلب الدفع.', 500);
|
||||||
|
}
|
||||||
79
app/modules_app/payments/stats.php
Normal file
79
app/modules_app/payments/stats.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Payment & Revenue Statistics (Super Admin)
|
||||||
|
* GET /api/v1/payments/stats
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
json_error('هذه الصفحة لمدير النظام فقط.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Total revenue
|
||||||
|
$stmt = $db->query("SELECT COALESCE(SUM(amount_jod), 0) as total_revenue FROM payment_requests WHERE status = 'approved'");
|
||||||
|
$totalRevenue = (float)$stmt->fetch()['total_revenue'];
|
||||||
|
|
||||||
|
// This month revenue
|
||||||
|
$stmt = $db->query("SELECT COALESCE(SUM(amount_jod), 0) as month_revenue FROM payment_requests WHERE status = 'approved' AND MONTH(verified_at) = MONTH(NOW()) AND YEAR(verified_at) = YEAR(NOW())");
|
||||||
|
$monthRevenue = (float)$stmt->fetch()['month_revenue'];
|
||||||
|
|
||||||
|
// Payment counts by status
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM payment_requests
|
||||||
|
GROUP BY status
|
||||||
|
");
|
||||||
|
$statusCounts = [];
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$statusCounts[$row['status']] = (int)$row['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active subscriptions count
|
||||||
|
$stmt = $db->query("SELECT COUNT(*) as active FROM subscriptions WHERE status = 'active' AND current_period_end > NOW()");
|
||||||
|
$activeSubscriptions = (int)$stmt->fetch()['active'];
|
||||||
|
|
||||||
|
// Revenue by plan
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT sp.name_ar, sp.name_en, COUNT(pr.id) as count, COALESCE(SUM(pr.amount_jod), 0) as revenue
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
|
||||||
|
WHERE pr.status = 'approved'
|
||||||
|
GROUP BY pr.plan_id
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
");
|
||||||
|
$revenueByPlan = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Recent payments (last 10)
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT pr.id, pr.amount_jod, pr.status, pr.internal_reference, pr.bank_reference, pr.created_at, pr.verified_at,
|
||||||
|
u.name AS payer_name, sp.name_ar AS plan_name
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN users u ON pr.user_id = u.id
|
||||||
|
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$recentPayments = $stmt->fetchAll();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'total_revenue' => $totalRevenue,
|
||||||
|
'month_revenue' => $monthRevenue,
|
||||||
|
'active_subscriptions' => $activeSubscriptions,
|
||||||
|
'payment_counts' => $statusCounts,
|
||||||
|
'revenue_by_plan' => $revenueByPlan,
|
||||||
|
'recent_payments' => $recentPayments,
|
||||||
|
], 'إحصائيات الإيرادات والاشتراكات');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Payment Stats Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء جلب الإحصائيات.', 500);
|
||||||
|
}
|
||||||
355
app/modules_app/payments/upload_receipt.php
Normal file
355
app/modules_app/payments/upload_receipt.php
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Upload Payment Receipt (Admin/Accountant)
|
||||||
|
* POST /api/v1/payments/upload-receipt
|
||||||
|
*
|
||||||
|
* Receives a screenshot/photo of the CliQ payment receipt.
|
||||||
|
* AI analyzes the image and matches against the payment request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\PaymentParser;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
if (!in_array($decoded['role'], ['admin', 'accountant'])) {
|
||||||
|
json_error('غير مصرح لك برفع وصل الدفع.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentId = $_POST['payment_id'] ?? null;
|
||||||
|
$rawBankRef = trim($_POST['bank_reference'] ?? '');
|
||||||
|
$bankRef = PaymentParser::extractReference($rawBankRef) ?: $rawBankRef;
|
||||||
|
|
||||||
|
if (!$paymentId) {
|
||||||
|
json_error('معرف طلب الدفع مطلوب.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasReceipt = isset($_FILES['receipt']) && $_FILES['receipt']['error'] === UPLOAD_ERR_OK;
|
||||||
|
|
||||||
|
if (!$bankRef && !$hasReceipt) {
|
||||||
|
json_error('الرجاء إدخال رقم المرجع (أو نص الرسالة) أو إرفاق صورة الوصل.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Verify payment request exists
|
||||||
|
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending','uploaded')");
|
||||||
|
$stmt->execute([$paymentId, $tenantId]);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the payment request with the provided bank reference
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET bank_reference = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$bankRef, $paymentId]);
|
||||||
|
$payment['bank_reference'] = $bankRef;
|
||||||
|
|
||||||
|
// 2. Immediate Check: Has the bot already received this transaction?
|
||||||
|
$stmt = $db->prepare("SELECT * FROM bank_transactions WHERE bank_reference = ? AND is_claimed = 0 LIMIT 1");
|
||||||
|
$stmt->execute([$bankRef]);
|
||||||
|
$transaction = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($transaction) {
|
||||||
|
$expectedAmount = (float)$payment['amount_jod'];
|
||||||
|
$actualAmount = (float)$transaction['amount'];
|
||||||
|
|
||||||
|
if (abs($expectedAmount - $actualAmount) < 0.01) {
|
||||||
|
// MATCH FOUND! Auto activate.
|
||||||
|
activateSubscription($db, $payment, $decoded['user_id']);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$paymentId]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$transaction['id']]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => 'approved',
|
||||||
|
'auto_verified' => true,
|
||||||
|
'message' => 'تم العثور على الحوالة وتفعيل اشتراكك فوراً! شكراً لك.'
|
||||||
|
], 'تم تفعيل الاشتراك بنجاح');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If no immediate match and no receipt image, we can't do more
|
||||||
|
if (!$hasReceipt && !$transaction) {
|
||||||
|
json_error('لم نتمكن من التحقق التلقائي من الرقم المرجعي. يرجى إرفاق صورة الوصل للمراجعة اليدوية.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aiResult = [];
|
||||||
|
$matchScore = 0.0;
|
||||||
|
$filepath = null;
|
||||||
|
|
||||||
|
if ($hasReceipt) {
|
||||||
|
$uploadDir = STORAGE_PATH . '/receipts/' . $tenantId;
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0750, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($_FILES['receipt']['name'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$filename = $paymentId . '_' . time() . '.' . $ext;
|
||||||
|
$filepath = $uploadDir . '/' . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($_FILES['receipt']['tmp_name'], $filepath)) {
|
||||||
|
json_error('فشل في حفظ صورة الوصل.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. AI Analysis of receipt image
|
||||||
|
$aiResult = analyzeReceipt($filepath, $payment);
|
||||||
|
|
||||||
|
// 5. Smart Match: Use AI-extracted reference to search bank_transactions
|
||||||
|
$aiExtractedRef = trim($aiResult['reference_number'] ?? '');
|
||||||
|
if (!empty($aiExtractedRef) && $aiExtractedRef !== 'unknown') {
|
||||||
|
$stmt = $db->prepare("SELECT * FROM bank_transactions WHERE bank_reference = ? AND is_claimed = 0 LIMIT 1");
|
||||||
|
$stmt->execute([$aiExtractedRef]);
|
||||||
|
$aiMatchTransaction = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($aiMatchTransaction) {
|
||||||
|
$expectedAmount = (float)$payment['amount_jod'];
|
||||||
|
$actualAmount = (float)$aiMatchTransaction['amount'];
|
||||||
|
|
||||||
|
if (abs($expectedAmount - $actualAmount) < 0.1) {
|
||||||
|
activateSubscription($db, $payment, $decoded['user_id']);
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', bank_reference = ?, receipt_image_path = ?, verified_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$aiExtractedRef, $filepath, $paymentId]);
|
||||||
|
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$aiMatchTransaction['id']]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => 'approved',
|
||||||
|
'auto_verified' => true,
|
||||||
|
'method' => 'ai_ref_matching',
|
||||||
|
'message' => 'تم العثور على الحوالة بنجاح وتفعيل الاشتراك آلياً!'
|
||||||
|
], 'تم تفعيل الاشتراك بنجاح');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Calculate match score
|
||||||
|
$matchScore = calculateMatchScore($aiResult, $payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Update payment request
|
||||||
|
$newStatus = $matchScore >= 85.0 ? 'verified' : 'uploaded';
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET receipt_image_path = ?,
|
||||||
|
ai_extracted_data = ?,
|
||||||
|
ai_match_score = ?,
|
||||||
|
status = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$filepath,
|
||||||
|
json_encode($aiResult, JSON_UNESCAPED_UNICODE),
|
||||||
|
$matchScore,
|
||||||
|
$newStatus,
|
||||||
|
$paymentId
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 8. Final attempt activation if high confidence
|
||||||
|
if ($matchScore >= 90.0) {
|
||||||
|
activateSubscription($db, $payment, $decoded['user_id']);
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$paymentId]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => 'approved',
|
||||||
|
'match_score' => $matchScore,
|
||||||
|
'message' => 'تم التحقق من الوصل وتفعيل الاشتراك تلقائياً بنسبة مطابقة عالية.'
|
||||||
|
], 'تم تفعيل الاشتراك');
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => $newStatus,
|
||||||
|
'match_score' => $matchScore,
|
||||||
|
'message' => $matchScore >= 70 ? 'تم استلام الوصل بنجاح، جاري المراجعة النهائية.' : 'تم استلام الطلب، بانتظار تأكيد الحوالة من البنك.'
|
||||||
|
], 'تم الاستلام');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Payment Receipt Upload Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء معالجة وصل الدفع.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze receipt image using Gemini AI
|
||||||
|
*/
|
||||||
|
function analyzeReceipt(string $imagePath, array $payment): array
|
||||||
|
{
|
||||||
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
|
if (!$apiKey) {
|
||||||
|
return ['error' => 'AI API key not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageData = base64_encode(file_get_contents($imagePath));
|
||||||
|
$mimeType = mime_content_type($imagePath) ?: 'image/jpeg';
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
أنت محلل وصولات دفع ذكي. حلل صورة وصل الدفع/التحويل البنكي واستخرج المعلومات التالية بدقة.
|
||||||
|
أرجع JSON فقط بدون أي نص إضافي:
|
||||||
|
|
||||||
|
{
|
||||||
|
"amount": <المبلغ المحول كرقم>,
|
||||||
|
"currency": "<العملة: JOD/USD/etc>",
|
||||||
|
"sender_name": "<اسم المرسل/الدافع>",
|
||||||
|
"receiver_name": "<اسم المستقبل>",
|
||||||
|
"reference_number": "<رقم المرجع أو رقم العملية>",
|
||||||
|
"transfer_date": "<تاريخ التحويل YYYY-MM-DD>",
|
||||||
|
"bank_name": "<اسم البنك>",
|
||||||
|
"is_valid_receipt": <true/false>,
|
||||||
|
"confidence": <نسبة الثقة 0-100>
|
||||||
|
}
|
||||||
|
|
||||||
|
المبلغ المتوقع: {$payment['amount_jod']} دينار أردني
|
||||||
|
رقم المرجع المتوقع: {$payment['reference_number']}
|
||||||
|
الاسم المستعار CliQ: {$payment['cliq_alias']}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$model = env('GEMINI_MODEL');
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => [
|
||||||
|
[
|
||||||
|
'parts' => [
|
||||||
|
['text' => $prompt],
|
||||||
|
[
|
||||||
|
'inline_data' => [
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'data' => $imageData
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'generationConfig' => [
|
||||||
|
'responseMimeType' => 'application/json',
|
||||||
|
'temperature' => 0.1
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_TIMEOUT => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("Gemini Receipt Analysis Error: $response");
|
||||||
|
return ['error' => 'AI analysis failed', 'is_valid_receipt' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$respData = json_decode($response, true);
|
||||||
|
$jsonText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
$parsed = json_decode($jsonText, true);
|
||||||
|
|
||||||
|
return $parsed ?: ['error' => 'Failed to parse AI response', 'is_valid_receipt' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate match score between AI extraction and expected payment
|
||||||
|
*/
|
||||||
|
function calculateMatchScore(array $aiResult, array $payment): float
|
||||||
|
{
|
||||||
|
if (!($aiResult['is_valid_receipt'] ?? false)) return 0.0;
|
||||||
|
|
||||||
|
$score = 0.0;
|
||||||
|
|
||||||
|
// Amount match (40 points)
|
||||||
|
$extractedAmount = (float)($aiResult['amount'] ?? 0);
|
||||||
|
$expectedAmount = (float)$payment['amount_jod'];
|
||||||
|
if (abs($extractedAmount - $expectedAmount) < 0.01) {
|
||||||
|
$score += 40;
|
||||||
|
} elseif (abs($extractedAmount - $expectedAmount) < 1.0) {
|
||||||
|
$score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference number match (30 points)
|
||||||
|
$extractedRef = strtoupper(trim($aiResult['reference_number'] ?? ''));
|
||||||
|
$expectedRef = strtoupper(trim($payment['reference_number']));
|
||||||
|
if ($extractedRef === $expectedRef) {
|
||||||
|
$score += 30;
|
||||||
|
} elseif (str_contains($extractedRef, $expectedRef) || str_contains($expectedRef, $extractedRef)) {
|
||||||
|
$score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiver name / CliQ alias match (15 points)
|
||||||
|
$receiverName = strtolower($aiResult['receiver_name'] ?? '');
|
||||||
|
$cliqAlias = strtolower($payment['cliq_alias']);
|
||||||
|
if (str_contains($receiverName, $cliqAlias) || str_contains($cliqAlias, $receiverName)) {
|
||||||
|
$score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI confidence boost (15 points)
|
||||||
|
$confidence = (float)($aiResult['confidence'] ?? 0);
|
||||||
|
$score += ($confidence / 100) * 15;
|
||||||
|
|
||||||
|
return min(round($score, 2), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-activate subscription upon verified payment
|
||||||
|
*/
|
||||||
|
function activateSubscription(\PDO $db, array $payment, string $userId): void
|
||||||
|
{
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$payment['plan_id']]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$plan) return;
|
||||||
|
|
||||||
|
$startDate = date('Y-m-d H:i:s');
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
|
||||||
|
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
plan_id = VALUES(plan_id),
|
||||||
|
max_companies = VALUES(max_companies),
|
||||||
|
max_invoices_per_month = VALUES(max_invoices_per_month),
|
||||||
|
max_users = VALUES(max_users),
|
||||||
|
price_jod = VALUES(price_jod),
|
||||||
|
status = 'active',
|
||||||
|
current_period_start = VALUES(current_period_start),
|
||||||
|
current_period_end = VALUES(current_period_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
't_id' => $payment['tenant_id'],
|
||||||
|
'p_id' => $plan['id'],
|
||||||
|
'max_c' => $plan['max_companies'],
|
||||||
|
'max_i' => $plan['max_invoices_month'],
|
||||||
|
'max_u' => $plan['max_users'],
|
||||||
|
'price' => $plan['price_jod'],
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Log activation
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, new_data) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$userId,
|
||||||
|
$payment['id'],
|
||||||
|
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true])
|
||||||
|
]);
|
||||||
|
}
|
||||||
156
app/modules_app/payments/verify_reference.php
Normal file
156
app/modules_app/payments/verify_reference.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Verify Bank Reference (Admin/Accountant)
|
||||||
|
* POST /api/v1/payments/verify-reference
|
||||||
|
*
|
||||||
|
* User submits the bank reference number from their bank app.
|
||||||
|
* We check if our Android bot has already received this reference.
|
||||||
|
* If yes, auto-activate. If no, mark as pending_verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Core\PaymentParser;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
if (!in_array($decoded['role'], ['admin', 'accountant', 'super_admin'])) {
|
||||||
|
json_error('غير مصرح لك بتأكيد الدفع.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$paymentId = $data['payment_id'] ?? null;
|
||||||
|
$rawBankRef = trim($data['bank_reference'] ?? '');
|
||||||
|
$bankReference = PaymentParser::extractReference($rawBankRef) ?: $rawBankRef;
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'payment_id' => 'required',
|
||||||
|
'bank_reference' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors || empty($bankReference)) {
|
||||||
|
json_error('رقم المرجع البنكي مطلوب.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Verify payment request exists and belongs to this tenant
|
||||||
|
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending', 'uploaded')");
|
||||||
|
$stmt->execute([$paymentId, $tenantId]);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 2. Check if the bot has already recorded this transaction
|
||||||
|
$stmt = $db->prepare("SELECT * FROM bank_transactions WHERE bank_reference = ? AND is_claimed = 0 LIMIT 1");
|
||||||
|
$stmt->execute([$bankReference]);
|
||||||
|
$transaction = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($transaction) {
|
||||||
|
// Match found! Check amount
|
||||||
|
$expectedAmount = (float)$payment['amount_jod'];
|
||||||
|
$receivedAmount = (float)$transaction['amount'];
|
||||||
|
|
||||||
|
if (abs($expectedAmount - $receivedAmount) < 0.01) {
|
||||||
|
// Amount matches exactly -> Auto Approve
|
||||||
|
activateSubscription($db, $payment, $decoded['user_id']);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', bank_reference = ?, verified_at = NOW() WHERE id = ?");
|
||||||
|
$stmt->execute([$bankReference, $paymentId]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$transaction['id']]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
json_success([
|
||||||
|
'status' => 'approved',
|
||||||
|
'message' => 'تم التحقق من الدفع وتفعيل الاشتراك تلقائياً!'
|
||||||
|
], 'تم اعتماد الدفع وتفعيل الاشتراك');
|
||||||
|
} else {
|
||||||
|
// Amount mismatch -> Needs manual review
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ?, admin_notes = 'المبلغ غير متطابق' WHERE id = ?");
|
||||||
|
$stmt->execute([$bankReference, $paymentId]);
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => 'uploaded',
|
||||||
|
'message' => 'تم العثور على الحوالة ولكن المبلغ غير متطابق. تم تحويل الطلب للمراجعة الإدارية.'
|
||||||
|
], 'قيد المراجعة');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No matching transaction found yet. Wait for the bot.
|
||||||
|
$stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$bankReference, $paymentId]);
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'status' => 'uploaded',
|
||||||
|
'message' => 'تم حفظ رقم المرجع بنجاح. سيتم تفعيل الاشتراك تلقائياً فور وصول تأكيد الحوالة من البنك.'
|
||||||
|
], 'تم حفظ المرجع (بانتظار التأكيد)');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Verify Reference Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء معالجة رقم المرجع.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-activate subscription upon verified payment
|
||||||
|
*/
|
||||||
|
function activateSubscription(\PDO $db, array $payment, string $userId): void
|
||||||
|
{
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$payment['plan_id']]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$plan) return;
|
||||||
|
|
||||||
|
$startDate = date('Y-m-d H:i:s');
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
|
||||||
|
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
plan_id = VALUES(plan_id),
|
||||||
|
max_companies = VALUES(max_companies),
|
||||||
|
max_invoices_per_month = VALUES(max_invoices_per_month),
|
||||||
|
max_users = VALUES(max_users),
|
||||||
|
price_jod = VALUES(price_jod),
|
||||||
|
status = 'active',
|
||||||
|
current_period_start = VALUES(current_period_start),
|
||||||
|
current_period_end = VALUES(current_period_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
't_id' => $payment['tenant_id'],
|
||||||
|
'p_id' => $plan['id'],
|
||||||
|
'max_c' => $plan['max_companies'],
|
||||||
|
'max_i' => $plan['max_invoices_month'],
|
||||||
|
'max_u' => $plan['max_users'],
|
||||||
|
'price' => $plan['price_jod'],
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Log activation
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, new_data) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$userId,
|
||||||
|
$payment['id'],
|
||||||
|
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true])
|
||||||
|
]);
|
||||||
|
}
|
||||||
86
app/modules_app/referral/apply.php
Normal file
86
app/modules_app/referral/apply.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Apply Referral Code During Registration
|
||||||
|
* POST /v1/referral/apply
|
||||||
|
* Body: { "referral_code": "MSQ-ABC123" }
|
||||||
|
*
|
||||||
|
* Called during registration to link a new user to their referrer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$code = $data['referral_code'] ?? null;
|
||||||
|
|
||||||
|
if (!$code) {
|
||||||
|
json_error('رمز الإحالة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Validate the referral code
|
||||||
|
$stmt = $db->prepare("SELECT * FROM referral_codes WHERE code = ? LIMIT 1");
|
||||||
|
$stmt->execute([$code]);
|
||||||
|
$referralCode = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$referralCode) {
|
||||||
|
json_error('رمز الإحالة غير صالح', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-referral
|
||||||
|
if ($referralCode['user_id'] === $userId) {
|
||||||
|
json_error('لا يمكنك استخدام رمز الإحالة الخاص بك', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already used a referral
|
||||||
|
$checkStmt = $db->prepare("SELECT id FROM referrals WHERE referred_id = ? LIMIT 1");
|
||||||
|
$checkStmt->execute([$userId]);
|
||||||
|
if ($checkStmt->fetch()) {
|
||||||
|
json_error('لقد استخدمت رمز إحالة مسبقاً', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the referral record
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$referralId = \App\Core\Database::generateUuid();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO referrals (id, referrer_id, referred_id, referral_code_id, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'registered', NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([$referralId, $referralCode['user_id'], $userId, $referralCode['id']]);
|
||||||
|
|
||||||
|
// 3. Notify the referrer
|
||||||
|
try {
|
||||||
|
$notifStmt = $db->prepare("
|
||||||
|
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, 'referral', '🎉 إحالة جديدة!', 'شخص جديد انضم باستخدام رمز إحالتك', ?, NOW())
|
||||||
|
");
|
||||||
|
$notifStmt->execute([
|
||||||
|
$referralCode['tenant_id'],
|
||||||
|
$referralCode['user_id'],
|
||||||
|
json_encode(['referral_id' => $referralId, 'code' => $code])
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail the whole operation if notification fails
|
||||||
|
error_log("[referral/apply] Notification failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'referral_id' => $referralId,
|
||||||
|
'referrer_code' => $code,
|
||||||
|
'status' => 'registered',
|
||||||
|
], 'تم تطبيق رمز الإحالة بنجاح! 🎉');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
safe_error($e, 'referral/apply', 'حدث خطأ في تطبيق رمز الإحالة.');
|
||||||
|
}
|
||||||
97
app/modules_app/referral/my_code.php
Normal file
97
app/modules_app/referral/my_code.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Referral System — Generate & Track Referral Codes
|
||||||
|
* GET /v1/referral/my-code — Get or generate user's referral code
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user already has a referral code
|
||||||
|
$stmt = $db->prepare("SELECT * FROM referral_codes WHERE user_id = ? LIMIT 1");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
$rewardRules = [
|
||||||
|
'per_registration' => '1 شهر مجاني على الباقة الحالية',
|
||||||
|
'per_subscription' => '2 شهر مجاني + رفع الحد 50 فاتورة',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Get referral stats (safe — returns zeros if no referrals yet)
|
||||||
|
$statsStmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_referrals,
|
||||||
|
SUM(CASE WHEN status = 'registered' THEN 1 ELSE 0 END) as registered,
|
||||||
|
SUM(CASE WHEN status = 'subscribed' THEN 1 ELSE 0 END) as subscribed,
|
||||||
|
SUM(CASE WHEN reward_claimed = 1 THEN 1 ELSE 0 END) as rewards_claimed
|
||||||
|
FROM referrals WHERE referrer_id = ?
|
||||||
|
");
|
||||||
|
$statsStmt->execute([$userId]);
|
||||||
|
$stats = $statsStmt->fetch();
|
||||||
|
|
||||||
|
// Recent referrals
|
||||||
|
$recent = [];
|
||||||
|
try {
|
||||||
|
$recentStmt = $db->prepare("
|
||||||
|
SELECT r.*, u.name as referred_name
|
||||||
|
FROM referrals r
|
||||||
|
LEFT JOIN users u ON r.referred_id = u.id
|
||||||
|
WHERE r.referrer_id = ?
|
||||||
|
ORDER BY r.created_at DESC LIMIT 10
|
||||||
|
");
|
||||||
|
$recentStmt->execute([$userId]);
|
||||||
|
$recent = $recentStmt->fetchAll();
|
||||||
|
|
||||||
|
// Decrypt names
|
||||||
|
foreach ($recent as &$ref) {
|
||||||
|
if (!empty($ref['referred_name'])) {
|
||||||
|
$dec = \App\Core\Encryption::decrypt($ref['referred_name']);
|
||||||
|
$ref['referred_name'] = ($dec !== false && $dec !== null) ? $dec : $ref['referred_name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// If referrals table query fails, just return empty
|
||||||
|
error_log("Referral recent query: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'code' => $existing['code'],
|
||||||
|
'link' => 'https://musadaq.intaleqapp.com/ref/' . $existing['code'],
|
||||||
|
'created_at' => $existing['created_at'],
|
||||||
|
'stats' => [
|
||||||
|
'total' => (int)($stats['total_referrals'] ?? 0),
|
||||||
|
'registered' => (int)($stats['registered'] ?? 0),
|
||||||
|
'subscribed' => (int)($stats['subscribed'] ?? 0),
|
||||||
|
'rewards_claimed' => (int)($stats['rewards_claimed'] ?? 0),
|
||||||
|
],
|
||||||
|
'recent' => $recent,
|
||||||
|
'reward_rules' => $rewardRules,
|
||||||
|
], 'رمز الإحالة الخاص بك');
|
||||||
|
} else {
|
||||||
|
// Generate new referral code
|
||||||
|
$code = 'MSQ-' . strtoupper(substr(md5($userId . time()), 0, 6));
|
||||||
|
|
||||||
|
$insertStmt = $db->prepare("INSERT INTO referral_codes (id, user_id, tenant_id, code, created_at) VALUES (UUID(), ?, ?, ?, NOW())");
|
||||||
|
$insertStmt->execute([$userId, $tenantId ?? '', $code]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'code' => $code,
|
||||||
|
'link' => 'https://musadaq.intaleqapp.com/ref/' . $code,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'stats' => ['total' => 0, 'registered' => 0, 'subscribed' => 0, 'rewards_claimed' => 0],
|
||||||
|
'recent' => [],
|
||||||
|
'reward_rules' => $rewardRules,
|
||||||
|
], 'تم إنشاء رمز الإحالة');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Referral error: " . $e->getMessage() . " | Trace: " . $e->getTraceAsString());
|
||||||
|
safe_error($e, 'referral/my_code', 'حدث خطأ في نظام الإحالة.');
|
||||||
|
}
|
||||||
142
app/modules_app/reports/company_health.php
Normal file
142
app/modules_app/reports/company_health.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Company Health Report
|
||||||
|
* GET /v1/reports/company-health?company_id=xxx
|
||||||
|
*
|
||||||
|
* Generates an AI-powered financial health analysis using invoice data.
|
||||||
|
* Returns insights, warnings, and recommendations in Arabic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$companyId = $_GET['company_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$companyId) {
|
||||||
|
json_error('معرّف الشركة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
$accessQuery = ($role === 'super_admin')
|
||||||
|
? "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND deleted_at IS NULL"
|
||||||
|
: "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL";
|
||||||
|
|
||||||
|
$accessParams = ($role === 'super_admin') ? [$companyId] : [$companyId, $tenantId];
|
||||||
|
$stmt = $db->prepare($accessQuery);
|
||||||
|
$stmt->execute($accessParams);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الشركة غير موجودة أو ليس لديك صلاحية', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyName = Encryption::decrypt($company['name']) ?: $company['name'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Gather last 3 months of data
|
||||||
|
$months = [];
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$m = date('m', strtotime("-{$i} months"));
|
||||||
|
$y = date('Y', strtotime("-{$i} months"));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as revenue,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax,
|
||||||
|
COALESCE(SUM(discount_total), 0) as discounts,
|
||||||
|
COALESCE(AVG(grand_total), 0) as avg_invoice,
|
||||||
|
SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count,
|
||||||
|
SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count
|
||||||
|
FROM invoices
|
||||||
|
WHERE company_id = ? AND MONTH(created_at) = ? AND YEAR(created_at) = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$companyId, $m, $y]);
|
||||||
|
$data = $stmt->fetch();
|
||||||
|
$data['month'] = (int)$m;
|
||||||
|
$data['year'] = (int)$y;
|
||||||
|
$months[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pending invoices count
|
||||||
|
$pendingStmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE company_id = ? AND status = 'extracted'");
|
||||||
|
$pendingStmt->execute([$companyId]);
|
||||||
|
$pendingCount = (int)$pendingStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 3. Build AI prompt
|
||||||
|
$dataJson = json_encode([
|
||||||
|
'company_name' => $companyName,
|
||||||
|
'tin' => $company['tax_identification_number'],
|
||||||
|
'monthly_data' => $months,
|
||||||
|
'pending_invoices' => $pendingCount,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
أنت محلل مالي خبير. حلل البيانات التالية لشركة وأعطِ تقريراً مختصراً بالعربية.
|
||||||
|
|
||||||
|
البيانات:
|
||||||
|
{$dataJson}
|
||||||
|
|
||||||
|
أعد الرد بصيغة JSON فقط بدون أي نص إضافي:
|
||||||
|
{
|
||||||
|
"health_score": (رقم من 1 إلى 10),
|
||||||
|
"health_label": ("ممتاز" أو "جيد" أو "متوسط" أو "يحتاج انتباه"),
|
||||||
|
"summary": "ملخص من سطرين عن الحالة المالية",
|
||||||
|
"insights": ["ملاحظة 1", "ملاحظة 2", "ملاحظة 3"],
|
||||||
|
"warnings": ["تحذير إن وجد"],
|
||||||
|
"recommendations": ["توصية 1", "توصية 2"]
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$aiResponse = AI::ask($prompt, $tenantId);
|
||||||
|
|
||||||
|
// Parse AI response
|
||||||
|
$report = null;
|
||||||
|
if ($aiResponse) {
|
||||||
|
// Extract JSON from response
|
||||||
|
$cleaned = preg_replace('/```json?\s*|```/', '', $aiResponse);
|
||||||
|
$report = json_decode(trim($cleaned), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if AI fails
|
||||||
|
if (!$report) {
|
||||||
|
$currentMonth = $months[0] ?? [];
|
||||||
|
$prevMonth = $months[1] ?? [];
|
||||||
|
$score = 5;
|
||||||
|
|
||||||
|
if (($currentMonth['total_invoices'] ?? 0) > 0) $score += 2;
|
||||||
|
if (($currentMonth['submitted_count'] ?? 0) > 0) $score += 1;
|
||||||
|
if ($pendingCount === 0) $score += 1;
|
||||||
|
if (($currentMonth['revenue'] ?? 0) > ($prevMonth['revenue'] ?? 0)) $score += 1;
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'health_score' => min(10, $score),
|
||||||
|
'health_label' => $score >= 8 ? 'ممتاز' : ($score >= 6 ? 'جيد' : 'متوسط'),
|
||||||
|
'summary' => 'تقرير مبني على البيانات المتوفرة بدون تحليل AI.',
|
||||||
|
'insights' => ['عدد الفواتير: ' . ($currentMonth['total_invoices'] ?? 0)],
|
||||||
|
'warnings' => $pendingCount > 0 ? ["يوجد {$pendingCount} فاتورة بانتظار المراجعة"] : [],
|
||||||
|
'recommendations' => ['تأكد من إرسال جميع الفواتير المعتمدة لجوفوترا'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'company_name' => $companyName,
|
||||||
|
'report' => $report,
|
||||||
|
'data' => [
|
||||||
|
'monthly_summary' => $months,
|
||||||
|
'pending_count' => $pendingCount,
|
||||||
|
],
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
], 'تقرير صحة الشركة');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'reports/company-health', 'حدث خطأ في إنشاء التقرير.');
|
||||||
|
}
|
||||||
154
app/modules_app/reports/tax_summary.php
Normal file
154
app/modules_app/reports/tax_summary.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Monthly Tax Report API
|
||||||
|
* GET /v1/reports/tax-summary
|
||||||
|
* Returns monthly summary of tax, revenue, and invoice statistics
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$companyId = $_GET['company_id'] ?? null;
|
||||||
|
$month = $_GET['month'] ?? date('m');
|
||||||
|
$year = $_GET['year'] ?? date('Y');
|
||||||
|
|
||||||
|
$where = ["MONTH(i.created_at) = ? AND YEAR(i.created_at) = ?"];
|
||||||
|
$params = [$month, $year];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'i.tenant_id = ?';
|
||||||
|
$params[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($companyId) {
|
||||||
|
$where[] = 'i.company_id = ?';
|
||||||
|
$params[] = $companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = 'WHERE ' . implode(' AND ', $where);
|
||||||
|
|
||||||
|
// 1. Main aggregation
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
SUM(CASE WHEN status = 'approved' OR status = 'submitted' THEN 1 ELSE 0 END) as approved_count,
|
||||||
|
SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count,
|
||||||
|
SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count,
|
||||||
|
COALESCE(SUM(subtotal), 0) as total_subtotal,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax,
|
||||||
|
COALESCE(SUM(discount_total), 0) as total_discount,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_grand,
|
||||||
|
COALESCE(AVG(grand_total), 0) as avg_invoice_amount,
|
||||||
|
COALESCE(MAX(grand_total), 0) as max_invoice_amount,
|
||||||
|
COALESCE(MIN(grand_total), 0) as min_invoice_amount
|
||||||
|
FROM invoices i
|
||||||
|
$whereClause
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$summary = $stmt->fetch();
|
||||||
|
|
||||||
|
// 2. Daily breakdown for chart
|
||||||
|
$stmtDaily = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
DAY(i.created_at) as day_num,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(grand_total), 0) as daily_total,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as daily_tax
|
||||||
|
FROM invoices i
|
||||||
|
$whereClause
|
||||||
|
GROUP BY DAY(i.created_at)
|
||||||
|
ORDER BY day_num
|
||||||
|
");
|
||||||
|
$stmtDaily->execute($params);
|
||||||
|
$dailyBreakdown = $stmtDaily->fetchAll();
|
||||||
|
|
||||||
|
// 3. Invoice type breakdown
|
||||||
|
$stmtType = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
invoice_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total
|
||||||
|
FROM invoices i
|
||||||
|
$whereClause
|
||||||
|
GROUP BY invoice_type
|
||||||
|
");
|
||||||
|
$stmtType->execute($params);
|
||||||
|
$typeBreakdown = $stmtType->fetchAll();
|
||||||
|
|
||||||
|
// 4. Top 5 suppliers
|
||||||
|
$stmtSuppliers = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
supplier_name,
|
||||||
|
COUNT(*) as invoice_count,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_amount
|
||||||
|
FROM invoices i
|
||||||
|
$whereClause
|
||||||
|
GROUP BY supplier_name
|
||||||
|
ORDER BY total_amount DESC
|
||||||
|
LIMIT 5
|
||||||
|
");
|
||||||
|
$stmtSuppliers->execute($params);
|
||||||
|
$topSuppliers = $stmtSuppliers->fetchAll();
|
||||||
|
|
||||||
|
// Decrypt supplier names
|
||||||
|
foreach ($topSuppliers as &$s) {
|
||||||
|
$decrypted = \App\Core\Encryption::decrypt($s['supplier_name']);
|
||||||
|
$s['supplier_name'] = ($decrypted !== false && $decrypted !== null) ? $decrypted : $s['supplier_name'];
|
||||||
|
}
|
||||||
|
unset($s);
|
||||||
|
|
||||||
|
// 5. Comparison with previous month
|
||||||
|
$prevMonth = $month == 1 ? 12 : $month - 1;
|
||||||
|
$prevYear = $month == 1 ? $year - 1 : $year;
|
||||||
|
|
||||||
|
$prevWhere = str_replace(
|
||||||
|
"MONTH(i.created_at) = ? AND YEAR(i.created_at) = ?",
|
||||||
|
"MONTH(i.created_at) = ? AND YEAR(i.created_at) = ?",
|
||||||
|
implode(' AND ', $where)
|
||||||
|
);
|
||||||
|
|
||||||
|
$prevParams = [$prevMonth, $prevYear];
|
||||||
|
if ($role !== 'super_admin') $prevParams[] = $tenantId;
|
||||||
|
if ($companyId) $prevParams[] = $companyId;
|
||||||
|
|
||||||
|
$stmtPrev = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_grand,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax
|
||||||
|
FROM invoices i
|
||||||
|
WHERE MONTH(i.created_at) = ? AND YEAR(i.created_at) = ?
|
||||||
|
" . ($role !== 'super_admin' ? " AND i.tenant_id = ?" : "")
|
||||||
|
. ($companyId ? " AND i.company_id = ?" : "")
|
||||||
|
);
|
||||||
|
$stmtPrev->execute($prevParams);
|
||||||
|
$previous = $stmtPrev->fetch();
|
||||||
|
|
||||||
|
// Calculate growth
|
||||||
|
$growth = [
|
||||||
|
'invoices' => $previous['total_invoices'] > 0
|
||||||
|
? round((($summary['total_invoices'] - $previous['total_invoices']) / $previous['total_invoices']) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
'revenue' => $previous['total_grand'] > 0
|
||||||
|
? round((($summary['total_grand'] - $previous['total_grand']) / $previous['total_grand']) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
'tax' => $previous['total_tax'] > 0
|
||||||
|
? round((($summary['total_tax'] - $previous['total_tax']) / $previous['total_tax']) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'month' => (int)$month,
|
||||||
|
'year' => (int)$year,
|
||||||
|
'summary' => $summary,
|
||||||
|
'daily_breakdown' => $dailyBreakdown,
|
||||||
|
'type_breakdown' => $typeBreakdown,
|
||||||
|
'top_suppliers' => $topSuppliers,
|
||||||
|
'previous_month' => $previous,
|
||||||
|
'growth' => $growth,
|
||||||
|
], 'تقرير ضريبة المبيعات الشهري');
|
||||||
188
app/modules_app/sms/receive.php
Normal file
188
app/modules_app/sms/receive.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SMS Bank Integration — Receive & Auto-Match Payments
|
||||||
|
* POST /v1/sms/receive
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Android SMS Bot intercepts bank/wallet SMS
|
||||||
|
* 2. Sends it here: { "sender": "BANK_NAME", "message": "تم تحويل 45 دينار..." }
|
||||||
|
* 3. We save it in raw_sms_log with status "pending"
|
||||||
|
* 4. We immediately try to match it against pending payment requests
|
||||||
|
* 5. If matched → confirm payment → update subscription → notify user
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
|
||||||
|
// Auth: Verify webhook secret (shared between Android bot and server)
|
||||||
|
$webhookSecret = env('SMS_WEBHOOK_SECRET', '');
|
||||||
|
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? $_SERVER['HTTP_X_SMS_SECRET'] ?? '';
|
||||||
|
|
||||||
|
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json_data = file_get_contents('php://input');
|
||||||
|
$data = json_decode($json_data, true);
|
||||||
|
|
||||||
|
if (!$data || empty($data['sender']) || empty($data['message'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'بيانات غير مكتملة. يجب إرسال sender و message.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = trim($data['sender']);
|
||||||
|
$message = trim($data['message']);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Save raw SMS log
|
||||||
|
$smsId = \App\Core\Database::generateUuid();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO raw_sms_log (id, sender, message_body, status, received_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([$smsId, $sender, $message]);
|
||||||
|
|
||||||
|
// 2. Try to auto-match with pending payments
|
||||||
|
$matchResult = matchPayment($db, $smsId, $sender, $message);
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'SMS received and processed.',
|
||||||
|
'matched' => $matchResult['matched'],
|
||||||
|
'details' => $matchResult['details'] ?? null,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("[sms/receive] Error: " . $e->getMessage());
|
||||||
|
http_response_code(200); // Return 200 so bot doesn't retry
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'خطأ داخلي في المعالجة.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to match the incoming SMS with a pending payment request.
|
||||||
|
*
|
||||||
|
* Matching logic:
|
||||||
|
* 1. Extract reference number from SMS (formats: MSQ-XXXX, REF-XXXX, or plain digits)
|
||||||
|
* 2. Extract amount from SMS
|
||||||
|
* 3. Find pending payment request matching reference OR amount
|
||||||
|
* 4. If matched → confirm payment → activate/extend subscription
|
||||||
|
*/
|
||||||
|
function matchPayment(\PDO $db, string $smsId, string $sender, string $message): array
|
||||||
|
{
|
||||||
|
// Extract reference number (MSQ-XXXX pattern or any 6+ digit number)
|
||||||
|
$reference = null;
|
||||||
|
if (preg_match('/MSQ-([A-Z0-9]{4,10})/i', $message, $m)) {
|
||||||
|
$reference = 'MSQ-' . strtoupper($m[1]);
|
||||||
|
} elseif (preg_match('/REF[:\s-]*([A-Z0-9]{4,12})/i', $message, $m)) {
|
||||||
|
$reference = $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract amount (Arabic or English digits)
|
||||||
|
$amount = null;
|
||||||
|
$msgNormalized = strtr($message, ['٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']);
|
||||||
|
if (preg_match('/(\d+[\.,]?\d{0,3})\s*(دينار|JOD|JD)/iu', $msgNormalized, $m)) {
|
||||||
|
$amount = (float)str_replace(',', '.', $m[1]);
|
||||||
|
} elseif (preg_match('/(\d+[\.,]\d{2})/', $msgNormalized, $m)) {
|
||||||
|
$amount = (float)str_replace(',', '.', $m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$reference && !$amount) {
|
||||||
|
// Can't match — mark SMS as unmatched
|
||||||
|
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', processed_at = NOW() WHERE id = ?")->execute([$smsId]);
|
||||||
|
return ['matched' => false, 'details' => 'لم يتم العثور على مرجع أو مبلغ في الرسالة'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for pending payment request
|
||||||
|
$where = "pr.status = 'pending'";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($reference) {
|
||||||
|
$where .= " AND pr.reference_number = ?";
|
||||||
|
$params[] = $reference;
|
||||||
|
}
|
||||||
|
if ($amount) {
|
||||||
|
$where .= " AND pr.amount = ?";
|
||||||
|
$params[] = $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT pr.*, t.name as tenant_name
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN tenants t ON pr.tenant_id = t.id
|
||||||
|
WHERE {$where}
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?")
|
||||||
|
->execute([$reference, $amount, $smsId]);
|
||||||
|
return ['matched' => false, 'details' => "مرجع: {$reference}, مبلغ: {$amount} — لم يتطابق مع أي طلب دفع"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MATCH FOUND — Process payment
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Update payment request → confirmed
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE payment_requests SET status = 'confirmed', sms_log_id = ?, confirmed_at = NOW() WHERE id = ?
|
||||||
|
")->execute([$smsId, $payment['id']]);
|
||||||
|
|
||||||
|
// 2. Update SMS log → matched
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE raw_sms_log SET status = 'matched', payment_request_id = ?, extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?
|
||||||
|
")->execute([$payment['id'], $reference, $amount, $smsId]);
|
||||||
|
|
||||||
|
// 3. Activate/extend subscription
|
||||||
|
$planMonths = (int)($payment['plan_months'] ?? 1);
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET is_active = 1,
|
||||||
|
started_at = COALESCE(started_at, NOW()),
|
||||||
|
expires_at = DATE_ADD(COALESCE(expires_at, NOW()), INTERVAL ? MONTH),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
")->execute([$planMonths, $payment['tenant_id']]);
|
||||||
|
|
||||||
|
// 4. Notify user
|
||||||
|
\App\Services\SmartNotifications::send(
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$payment['user_id'] ?? '',
|
||||||
|
'payment_confirmed',
|
||||||
|
'✅ تم تأكيد الدفع!',
|
||||||
|
"تم تأكيد دفعة بقيمة {$payment['amount']} دينار. اشتراكك فعّال الآن.",
|
||||||
|
['payment_id' => $payment['id'], 'amount' => $payment['amount']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Audit log
|
||||||
|
AuditLogger::log('payment.auto_confirmed', 'payment', $payment['id'], null, [
|
||||||
|
'sms_id' => $smsId,
|
||||||
|
'sender' => $sender,
|
||||||
|
'reference' => $reference,
|
||||||
|
'amount' => $amount,
|
||||||
|
], ['user_id' => 'system', 'tenant_id' => $payment['tenant_id'], 'role' => 'system']);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'matched' => true,
|
||||||
|
'details' => "تم مطابقة الدفعة: {$payment['amount']} دينار — الاشتراك مُفعّل",
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
error_log("[sms/match] Failed: " . $e->getMessage());
|
||||||
|
return ['matched' => false, 'details' => 'خطأ أثناء تأكيد الدفعة'];
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/modules_app/subscriptions/assign.php
Normal file
95
app/modules_app/subscriptions/assign.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Assign/Upgrade Subscription Plan (Super Admin only)
|
||||||
|
* POST /api/v1/subscriptions/assign
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
// Only Super Admin can change plans manually via this API
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
json_error('غير مصرح لك بتغيير الباقات. يرجى التواصل مع الدعم الفني.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = input();
|
||||||
|
$targetTenantId = $data['tenant_id'] ?? null;
|
||||||
|
$planId = $data['plan_id'] ?? null;
|
||||||
|
$durationDays = (int)($data['duration_days'] ?? 30);
|
||||||
|
|
||||||
|
if (!$targetTenantId || !$planId) {
|
||||||
|
json_error('معرف المكتب ومعرف الباقة مطلوبان.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Validate Plan
|
||||||
|
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$planId]);
|
||||||
|
$plan = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$plan) {
|
||||||
|
json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update or Create Subscription
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$startDate = date('Y-m-d H:i:s');
|
||||||
|
$endDate = date('Y-m-d H:i:s', strtotime("+{$durationDays} days"));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO subscriptions (
|
||||||
|
tenant_id, plan_id, max_companies, max_invoices_per_month, max_users,
|
||||||
|
price_jod, status, current_period_start, current_period_end, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
plan_id = VALUES(plan_id),
|
||||||
|
max_companies = VALUES(max_companies),
|
||||||
|
max_invoices_per_month = VALUES(max_invoices_per_month),
|
||||||
|
max_users = VALUES(max_users),
|
||||||
|
price_jod = VALUES(price_jod),
|
||||||
|
status = 'active',
|
||||||
|
current_period_start = VALUES(current_period_start),
|
||||||
|
current_period_end = VALUES(current_period_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
't_id' => $targetTenantId,
|
||||||
|
'p_id' => $planId,
|
||||||
|
'max_c' => $plan['max_companies'],
|
||||||
|
'max_i' => $plan['max_invoices_month'],
|
||||||
|
'max_u' => $plan['max_users'],
|
||||||
|
'price' => $plan['price_jod'],
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Log the change
|
||||||
|
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'plan_assigned', 'tenant', ?, ?)");
|
||||||
|
$logStmt->execute([
|
||||||
|
$targetTenantId,
|
||||||
|
$decoded['user_id'],
|
||||||
|
$targetTenantId,
|
||||||
|
json_encode(['plan_id' => $planId, 'assigned_by' => $decoded['user_id']])
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'tenant_id' => $targetTenantId,
|
||||||
|
'plan_id' => $planId,
|
||||||
|
'period_end' => $endDate
|
||||||
|
], 'تم تحديث باقة الاشتراك بنجاح');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Subscription Assign Error: " . $e->getMessage());
|
||||||
|
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
|
||||||
|
}
|
||||||
25
app/modules_app/subscriptions/current.php
Normal file
25
app/modules_app/subscriptions/current.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Get Current Tenant Subscription & Usage
|
||||||
|
* GET /api/v1/subscriptions/current
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$usage = QuotaMiddleware::getUsageSummary($tenantId);
|
||||||
|
|
||||||
|
if (!$usage['has_subscription']) {
|
||||||
|
json_error('لم يتم العثور على اشتراك نشط لهذا المكتب.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($usage, 'تفاصيل الاشتراك الحالي');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Subscription Current Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء جلب بيانات الاشتراك', 500);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user