Compare commits
140 Commits
2176893eee
...
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 |
141
app/Core/AI.php
141
app/Core/AI.php
@@ -10,7 +10,9 @@ use App\Services\InvoiceExtractionService;
|
|||||||
*/
|
*/
|
||||||
class AI
|
class AI
|
||||||
{
|
{
|
||||||
private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent";
|
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)
|
* Extract Data from Invoice Image or PDF (Base64)
|
||||||
@@ -23,14 +25,13 @@ class AI
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = new InvoiceExtractionService();
|
$prompt = AIConfig::getExtractionPrompt();
|
||||||
$prompt = $service->buildExtractionPrompt();
|
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
"contents" => [
|
"contents" => [
|
||||||
[
|
[
|
||||||
"parts" => [
|
"parts" => [
|
||||||
["text" => $prompt],
|
["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" => [
|
"inline_data" => [
|
||||||
"mime_type" => $mimeType,
|
"mime_type" => $mimeType,
|
||||||
@@ -45,18 +46,49 @@ class AI
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
|
// Retry with exponential backoff for 503/429 errors
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
for ($attempt = 1; $attempt <= self::$maxRetries; $attempt++) {
|
||||||
curl_setopt($ch, CURLOPT_POST, true);
|
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
$response = curl_exec($ch);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||||
curl_close($ch);
|
|
||||||
|
$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) {
|
if ($httpCode !== 200) {
|
||||||
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
|
error_log("AI Error: All retries exhausted. Last code: $httpCode");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +96,86 @@ class AI
|
|||||||
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||||
|
|
||||||
if (!$textResponse) return 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);
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
return json_decode($textResponse, true);
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,36 @@ final class Validator
|
|||||||
{
|
{
|
||||||
$errors = [];
|
$errors = [];
|
||||||
foreach ($rules as $field => $rule) {
|
foreach ($rules as $field => $rule) {
|
||||||
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
|
$value = $data[$field] ?? null;
|
||||||
|
|
||||||
|
if (str_contains($rule, 'required') && (empty($value) && $value !== '0')) {
|
||||||
$errors[$field] = "The {$field} field is required.";
|
$errors[$field] = "The {$field} field is required.";
|
||||||
|
continue; // Skip further rules if required field is missing
|
||||||
}
|
}
|
||||||
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
|
|
||||||
|
if (str_contains($rule, 'email') && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
$errors[$field] = "The {$field} must be a valid email address.";
|
$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;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Middleware;
|
namespace App\Middleware;
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
final class QuotaMiddleware
|
final class QuotaMiddleware
|
||||||
{
|
{
|
||||||
@@ -22,17 +23,26 @@ final class QuotaMiddleware
|
|||||||
*/
|
*/
|
||||||
public static function checkInvoiceQuota(string $tenantId): array
|
public static function checkInvoiceQuota(string $tenantId): array
|
||||||
{
|
{
|
||||||
$db = Database::getInstance();
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
// Fetch subscription with plan info
|
if ($sub === false || $sub === null) {
|
||||||
$stmt = $db->prepare("
|
$db = Database::getInstance();
|
||||||
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
|
|
||||||
FROM subscriptions s
|
// Fetch subscription with plan info
|
||||||
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
$stmt = $db->prepare("
|
||||||
WHERE s.tenant_id = ?
|
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
|
||||||
$stmt->execute([$tenantId]);
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
$sub = $stmt->fetch();
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($sub) {
|
||||||
|
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!$sub) {
|
if (!$sub) {
|
||||||
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
||||||
@@ -47,10 +57,12 @@ final class QuotaMiddleware
|
|||||||
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-reset monthly counter if billing period has ended
|
// Auto-reset period counter if billing period has ended
|
||||||
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
||||||
$newStart = date('Y-m-d H:i:s');
|
$newStart = date('Y-m-d H:i:s');
|
||||||
$newEnd = date('Y-m-d H:i:s', strtotime('+30 days'));
|
$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("
|
$resetStmt = $db->prepare("
|
||||||
UPDATE subscriptions
|
UPDATE subscriptions
|
||||||
@@ -66,15 +78,15 @@ final class QuotaMiddleware
|
|||||||
$sub['current_period_start'] = $newStart;
|
$sub['current_period_start'] = $newStart;
|
||||||
$sub['current_period_end'] = $newEnd;
|
$sub['current_period_end'] = $newEnd;
|
||||||
|
|
||||||
error_log("QuotaMiddleware: Auto-reset monthly counter for tenant {$tenantId}");
|
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check invoice quota
|
// Check invoice quota
|
||||||
$used = (int)$sub['invoices_used_this_month'];
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
$limit = (int)$sub['max_invoices_per_month'];
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
|
||||||
if ($used >= $limit) {
|
if ($used >= $limit) {
|
||||||
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
|
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
||||||
'quota_type' => 'invoices',
|
'quota_type' => 'invoices',
|
||||||
'used' => $used,
|
'used' => $used,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
@@ -100,6 +112,9 @@ final class QuotaMiddleware
|
|||||||
WHERE tenant_id = ?
|
WHERE tenant_id = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([$tenantId]);
|
$stmt->execute([$tenantId]);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
Cache::delete("quota_sub_{$tenantId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,6 +245,11 @@ final class QuotaMiddleware
|
|||||||
$companiesLimit = (int)$sub['max_companies'];
|
$companiesLimit = (int)$sub['max_companies'];
|
||||||
$usersLimit = (int)($sub['max_users'] ?? 999);
|
$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 [
|
return [
|
||||||
'has_subscription' => true,
|
'has_subscription' => true,
|
||||||
'plan_id' => $sub['plan_id'] ?? 'free',
|
'plan_id' => $sub['plan_id'] ?? 'free',
|
||||||
@@ -239,6 +259,11 @@ final class QuotaMiddleware
|
|||||||
'status' => $sub['status'],
|
'status' => $sub['status'],
|
||||||
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
||||||
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? 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' => [
|
'invoices' => [
|
||||||
'used' => $invoicesUsed,
|
'used' => $invoicesUsed,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,16 @@ class InvoiceExtractionService
|
|||||||
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
|
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
|
||||||
|
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
## التحقق الرياضي (إلزامي):
|
## التحقق الرياضي والفواتير الشاملة للضريبة (إلزامي):
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
- line_total = (quantity × unit_price) - discount لكل سطر
|
- معظم فواتير التجزئة والسوبرماركت (POS) في الأردن تكون "شاملة للضريبة" (Tax Inclusive).
|
||||||
|
- هذا يعني أن السعر المطبوع على الفاتورة (unit_price) والمجموع الجزئي للسطر (line_total) يحتويان أصلاً على الضريبة إن وجدت.
|
||||||
|
- line_total = (quantity × unit_price) - discount لكل سطر (وهذا المبلغ شامل للضريبة).
|
||||||
- subtotal = مجموع كل line_total
|
- subtotal = مجموع كل line_total
|
||||||
- tax_amount = مجموع (line_total × tax_rate) لكل سطر
|
- grand_total = subtotal - discount_total (يجب أن يتطابق تماماً مع المبلغ الكلي المطلوب من العميل في الفاتورة).
|
||||||
- grand_total = subtotal - discount_total + tax_amount
|
- tax_amount = مجموع الضرائب المحسوبة عكسياً من line_total (أو كما هي مذكورة صراحةً في أسفل الفاتورة). إياك أن تضيف tax_amount فوق subtotal إذا كانت الفاتورة شاملة للضريبة.
|
||||||
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: سجِّله في validation_warnings، واستخدم القيم المحسوبة
|
- إذا كانت الفاتورة من النوع النادر غير الشامل للضريبة (Tax Exclusive): grand_total = subtotal - discount_total + tax_amount
|
||||||
|
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: يجب أن تعطي الأولوية القصوى لتطابق `grand_total` مع الرقم المطبوع الذي تم دفعه فعلياً، وسجِّل أي ملاحظات في validation_warnings.
|
||||||
|
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
## جدول الضرائب الأردنية (مرجعك الإلزامي):
|
## جدول الضرائب الأردنية (مرجعك الإلزامي):
|
||||||
@@ -90,7 +93,8 @@ class InvoiceExtractionService
|
|||||||
- زيت الزيتون غير المعدل كيماوياً
|
- زيت الزيتون غير المعدل كيماوياً
|
||||||
- سكر مكرر (عدا سكر القصب)
|
- سكر مكرر (عدا سكر القصب)
|
||||||
- الشاي الأسود (عبوات ≤ 3 كغ)
|
- الشاي الأسود (عبوات ≤ 3 كغ)
|
||||||
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (≤ 3 كغ)
|
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (مثل حليب نيدو)
|
||||||
|
- الألبان (اللبن الرائب، الشنينة، لبن حمودة، الخ) والأجبان البيضاء العادية.
|
||||||
- بيض المائدة
|
- بيض المائدة
|
||||||
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
|
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
|
||||||
- أجهزة الهواتف الذكية
|
- أجهزة الهواتف الذكية
|
||||||
@@ -106,10 +110,10 @@ class InvoiceExtractionService
|
|||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
## قواعد تصنيف الضريبة لكل سطر:
|
## قواعد تصنيف الضريبة لكل سطر:
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة
|
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ).
|
||||||
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية
|
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات.
|
||||||
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
|
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
|
||||||
4. tax_category: استخدم "S" للخاضعة (16% أو مخفضة)، "Z" للصفري، "E" للمعفاة، "O" للخاصة
|
4. tax_category: استخدم "standard" للخاضعة (16% أو مخفضة)، "zero_rated" للصفري، "exempt" للمعفاة، "special" للخاصة
|
||||||
|
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
## تصنيف طريقة الدفع:
|
## تصنيف طريقة الدفع:
|
||||||
@@ -123,42 +127,46 @@ class InvoiceExtractionService
|
|||||||
## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
|
## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
{
|
{
|
||||||
"invoice_number": "string | null",
|
"invoices": [
|
||||||
"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,
|
"invoice_number": "string | null",
|
||||||
"description": "string",
|
"invoice_date": "YYYY-MM-DD | null",
|
||||||
"quantity": 1.000,
|
"invoice_type": "cash | credit",
|
||||||
"unit_price": 0.000,
|
"payment_method_code": "013 | 010 | 001",
|
||||||
"discount": 0.000,
|
"ubl_type_code": "388",
|
||||||
"tax_rate": 0.16,
|
"supplier": {
|
||||||
"tax_category": "S | Z | E | O",
|
"name": "string | null",
|
||||||
"tax_exempt_reason": "string | null",
|
"tin": "string | null",
|
||||||
"line_total": 0.000
|
"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
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"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;
|
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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,20 @@ 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
|
// Self-healing Storage
|
||||||
$dirs = ['/cache', '/logs', '/invoices', '/exports'];
|
$dirs = ['/cache', '/logs', '/invoices', '/exports'];
|
||||||
foreach ($dirs as $d) {
|
foreach ($dirs as $d) {
|
||||||
$path = STORAGE_PATH . $d;
|
$path = STORAGE_PATH . $d;
|
||||||
if (!is_dir($path)) {
|
if (!is_dir($path)) {
|
||||||
mkdir($path, 0777, true);
|
mkdir($path, 0755, true);
|
||||||
chmod($path, 0777);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +66,27 @@ header("X-Content-Type-Options: nosniff");
|
|||||||
header("X-Frame-Options: SAMEORIGIN");
|
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=()");
|
||||||
|
|
||||||
|
// 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)
|
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
return [
|
return [
|
||||||
'free' => [
|
'free' => [
|
||||||
'id' => 'free',
|
'id' => 'free',
|
||||||
'name_ar' => 'مجانية',
|
'name_ar' => 'التجربة المجانية',
|
||||||
'name_en' => 'Free',
|
'name_en' => 'Free Trial',
|
||||||
'max_companies' => 1,
|
'max_companies' => 1,
|
||||||
'max_invoices_month' => 15,
|
'max_invoices_month' => 15,
|
||||||
'max_users' => 1,
|
'max_users' => 1,
|
||||||
'price_jod' => 0.00,
|
'price_jod' => 0.00,
|
||||||
|
'price_monthly_jod' => 0.00,
|
||||||
|
'price_annual_jod' => 0.00,
|
||||||
'ai_features' => true,
|
'ai_features' => true,
|
||||||
'jofotara_enabled' => true,
|
'jofotara_enabled' => true,
|
||||||
'badge_color' => 'gray',
|
'badge_color' => 'gray',
|
||||||
@@ -29,90 +31,50 @@ return [
|
|||||||
],
|
],
|
||||||
'basic' => [
|
'basic' => [
|
||||||
'id' => 'basic',
|
'id' => 'basic',
|
||||||
'name_ar' => 'أساسية',
|
'name_ar' => 'الباقة الأساسية',
|
||||||
'name_en' => 'Basic',
|
'name_en' => 'Basic Plan',
|
||||||
'max_companies' => 3,
|
'max_companies' => 3,
|
||||||
'max_invoices_month' => 100,
|
'max_invoices_month' => 500,
|
||||||
'max_users' => 3,
|
'max_users' => 2,
|
||||||
'price_jod' => 15.00,
|
'price_jod' => 15.00, // Default legacy price
|
||||||
|
'price_monthly_jod' => 15.00,
|
||||||
|
'price_annual_jod' => 120.00,
|
||||||
'ai_features' => true,
|
'ai_features' => true,
|
||||||
'jofotara_enabled' => true,
|
'jofotara_enabled' => true,
|
||||||
'badge_color' => 'blue',
|
'badge_color' => 'blue',
|
||||||
'description_ar' => 'للمحاسبين المستقلين — 3 شركات',
|
'description_ar' => 'للمحاسبين المستقلين والشركات الصغيرة — 3 شركات',
|
||||||
'features' => [
|
'features' => [
|
||||||
'استخراج الفواتير بالذكاء الاصطناعي',
|
'استخراج الفواتير بالذكاء الاصطناعي',
|
||||||
'الربط المباشر مع جوفوترة',
|
'الربط المباشر مع جوفوترة',
|
||||||
'حتى 3 شركات',
|
'حتى 3 شركات (بدلاً من واحدة)',
|
||||||
'100 فاتورة شهرياً',
|
'500 فاتورة شهرياً (سخية جداً)',
|
||||||
'3 مستخدمين',
|
'مستخدمين اثنين',
|
||||||
'تقارير شهرية',
|
'دعم فني عبر الواتساب',
|
||||||
],
|
|
||||||
],
|
|
||||||
'office' => [
|
|
||||||
'id' => 'office',
|
|
||||||
'name_ar' => 'مكتبية',
|
|
||||||
'name_en' => 'Office',
|
|
||||||
'max_companies' => 10,
|
|
||||||
'max_invoices_month' => 500,
|
|
||||||
'max_users' => 10,
|
|
||||||
'price_jod' => 45.00,
|
|
||||||
'ai_features' => true,
|
|
||||||
'jofotara_enabled' => true,
|
|
||||||
'badge_color' => 'teal',
|
|
||||||
'is_popular' => true,
|
|
||||||
'description_ar' => 'للمكاتب المحاسبية — ربط مباشر بجوفوترة',
|
|
||||||
'features' => [
|
|
||||||
'كل ميزات الأساسية',
|
|
||||||
'ربط مباشر بنظام JoFotara',
|
|
||||||
'حتى 10 شركات',
|
|
||||||
'500 فاتورة شهرياً',
|
|
||||||
'10 مستخدمين',
|
|
||||||
'تقارير متقدمة + تصدير',
|
|
||||||
'دعم فني بالأولوية',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'pro' => [
|
'pro' => [
|
||||||
'id' => 'pro',
|
'id' => 'pro',
|
||||||
'name_ar' => 'احترافية',
|
'name_ar' => 'الباقة الاحترافية',
|
||||||
'name_en' => 'Pro',
|
'name_en' => 'Pro Plan',
|
||||||
'max_companies' => 25,
|
'max_companies' => 9999,
|
||||||
'max_invoices_month' => 2000,
|
'max_invoices_month' => 3000,
|
||||||
'max_users' => 25,
|
'max_users' => 5,
|
||||||
'price_jod' => 99.00,
|
'price_jod' => 35.00, // Default legacy price
|
||||||
'ai_features' => true,
|
'price_monthly_jod' => 35.00,
|
||||||
'jofotara_enabled' => true,
|
'price_annual_jod' => 290.00,
|
||||||
'badge_color' => 'navy',
|
|
||||||
'description_ar' => 'للمكاتب الكبيرة — حجم عمل ضخم بلا حدود عملية',
|
|
||||||
'features' => [
|
|
||||||
'كل ميزات المكتبية',
|
|
||||||
'حتى 25 شركة',
|
|
||||||
'2000 فاتورة شهرياً',
|
|
||||||
'25 مستخدم',
|
|
||||||
'API كامل لتطبيق الهاتف',
|
|
||||||
'تدقيق ذكي بالـ AI (Pre-Audit)',
|
|
||||||
'مدير حساب مخصص',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'enterprise' => [
|
|
||||||
'id' => 'enterprise',
|
|
||||||
'name_ar' => 'مؤسسية',
|
|
||||||
'name_en' => 'Enterprise',
|
|
||||||
'max_companies' => 999,
|
|
||||||
'max_invoices_month' => 99999,
|
|
||||||
'max_users' => 999,
|
|
||||||
'price_jod' => 249.00,
|
|
||||||
'ai_features' => true,
|
'ai_features' => true,
|
||||||
'jofotara_enabled' => true,
|
'jofotara_enabled' => true,
|
||||||
'badge_color' => 'gold',
|
'badge_color' => 'gold',
|
||||||
'description_ar' => 'للمؤسسات — بلا حدود مع دعم مخصص',
|
'is_popular' => true,
|
||||||
|
'description_ar' => 'للمكاتب الكبيرة والموزعين — حجم عمل ضخم',
|
||||||
'features' => [
|
'features' => [
|
||||||
'كل ميزات الاحترافية',
|
'استخراج الفواتير بالذكاء الاصطناعي',
|
||||||
'شركات وفواتير بلا حدود عملية',
|
'الربط المباشر مع جوفوترة',
|
||||||
'مستخدمين بلا حدود',
|
'عدد شركات غير محدود',
|
||||||
'SLA مضمون 99.9%',
|
'3,000 فاتورة شهرياً',
|
||||||
'ربط API مخصص',
|
'5 مستخدمين',
|
||||||
'تدريب فريق المحاسبة',
|
'API كامل لتطبيق الهاتف',
|
||||||
'نسخ احتياطي مخصص',
|
'مدير حساب مخصص',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Middleware;
|
namespace App\Middleware;
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
final class QuotaMiddleware
|
final class QuotaMiddleware
|
||||||
{
|
{
|
||||||
@@ -22,17 +23,26 @@ final class QuotaMiddleware
|
|||||||
*/
|
*/
|
||||||
public static function checkInvoiceQuota(string $tenantId): array
|
public static function checkInvoiceQuota(string $tenantId): array
|
||||||
{
|
{
|
||||||
$db = Database::getInstance();
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
// Fetch subscription with plan info
|
if ($sub === false || $sub === null) {
|
||||||
$stmt = $db->prepare("
|
$db = Database::getInstance();
|
||||||
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
|
|
||||||
FROM subscriptions s
|
// Fetch subscription with plan info
|
||||||
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
$stmt = $db->prepare("
|
||||||
WHERE s.tenant_id = ?
|
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
|
||||||
$stmt->execute([$tenantId]);
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
$sub = $stmt->fetch();
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($sub) {
|
||||||
|
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!$sub) {
|
if (!$sub) {
|
||||||
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
||||||
@@ -47,10 +57,12 @@ final class QuotaMiddleware
|
|||||||
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-reset monthly counter if billing period has ended
|
// Auto-reset period counter if billing period has ended
|
||||||
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
||||||
$newStart = date('Y-m-d H:i:s');
|
$newStart = date('Y-m-d H:i:s');
|
||||||
$newEnd = date('Y-m-d H:i:s', strtotime('+30 days'));
|
$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("
|
$resetStmt = $db->prepare("
|
||||||
UPDATE subscriptions
|
UPDATE subscriptions
|
||||||
@@ -66,15 +78,15 @@ final class QuotaMiddleware
|
|||||||
$sub['current_period_start'] = $newStart;
|
$sub['current_period_start'] = $newStart;
|
||||||
$sub['current_period_end'] = $newEnd;
|
$sub['current_period_end'] = $newEnd;
|
||||||
|
|
||||||
error_log("QuotaMiddleware: Auto-reset monthly counter for tenant {$tenantId}");
|
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check invoice quota
|
// Check invoice quota
|
||||||
$used = (int)$sub['invoices_used_this_month'];
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
$limit = (int)$sub['max_invoices_per_month'];
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
|
||||||
if ($used >= $limit) {
|
if ($used >= $limit) {
|
||||||
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
|
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
||||||
'quota_type' => 'invoices',
|
'quota_type' => 'invoices',
|
||||||
'used' => $used,
|
'used' => $used,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
@@ -100,6 +112,9 @@ final class QuotaMiddleware
|
|||||||
WHERE tenant_id = ?
|
WHERE tenant_id = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([$tenantId]);
|
$stmt->execute([$tenantId]);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
Cache::delete("quota_sub_{$tenantId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,6 +245,11 @@ final class QuotaMiddleware
|
|||||||
$companiesLimit = (int)$sub['max_companies'];
|
$companiesLimit = (int)$sub['max_companies'];
|
||||||
$usersLimit = (int)($sub['max_users'] ?? 999);
|
$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 [
|
return [
|
||||||
'has_subscription' => true,
|
'has_subscription' => true,
|
||||||
'plan_id' => $sub['plan_id'] ?? 'free',
|
'plan_id' => $sub['plan_id'] ?? 'free',
|
||||||
@@ -239,6 +259,11 @@ final class QuotaMiddleware
|
|||||||
'status' => $sub['status'],
|
'status' => $sub['status'],
|
||||||
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
||||||
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? 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' => [
|
'invoices' => [
|
||||||
'used' => $invoicesUsed,
|
'used' => $invoicesUsed,
|
||||||
|
|||||||
@@ -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', 'خطأ في جلب سجل النشاط.');
|
||||||
|
}
|
||||||
@@ -39,38 +39,140 @@ 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'],
|
||||||
'tenant_id' => $user['tenant_id'],
|
'tenant_id' => $user['tenant_id'],
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
'exp' => time() + (15 * 60) // 15 minutes
|
'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']]);
|
||||||
|
|
||||||
// 7. Secure Refresh Token delivery via HttpOnly Cookie
|
// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
|
||||||
setcookie('refresh_token', $refreshToken, [
|
if (!$deviceId) {
|
||||||
'expires' => time() + (7 * 24 * 60 * 60), // 7 days
|
setcookie('refresh_token', $refreshToken, [
|
||||||
'path' => '/api/v1/auth/refresh',
|
'expires' => time() + (7 * 24 * 60 * 60), // 7 days
|
||||||
'secure' => true,
|
'path' => '/api/v1/auth/refresh',
|
||||||
'httponly' => true,
|
'secure' => true,
|
||||||
'samesite' => 'Strict',
|
'httponly' => true,
|
||||||
]);
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'access_token' => $token,
|
'access_token' => $token,
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'device_secret' => $deviceSecret,
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user['id'],
|
'id' => $user['id'],
|
||||||
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
|
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
|
||||||
|
|||||||
@@ -17,121 +17,105 @@ use App\Middleware\RateLimitMiddleware;
|
|||||||
// Rate limit: 3 OTP requests per minute per IP
|
// Rate limit: 3 OTP requests per minute per IP
|
||||||
RateLimitMiddleware::check(3, 60);
|
RateLimitMiddleware::check(3, 60);
|
||||||
|
|
||||||
$data = Security::sanitize(input());
|
try {
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
// 1. Validate
|
// 1. Validate
|
||||||
$errors = Validator::validate($data, [
|
$errors = Validator::validate($data, [
|
||||||
'phone' => 'required',
|
'phone' => 'required',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($errors) {
|
if ($errors) {
|
||||||
json_error('رقم الهاتف مطلوب', 422, $errors);
|
json_error('رقم الهاتف مطلوب', 422, $errors);
|
||||||
}
|
|
||||||
|
|
||||||
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
|
||||||
$phoneHash = hash('sha256', $phone);
|
|
||||||
|
|
||||||
// 2. Find user by phone hash
|
|
||||||
$db = Database::getInstance();
|
|
||||||
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone_hash = ? LIMIT 1");
|
|
||||||
$stmt->execute([$phoneHash]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
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)
|
|
||||||
// Using a simple approach: store in a cache file per 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Send OTP via SMS
|
|
||||||
// TODO: Replace with your actual SMS provider
|
|
||||||
$smsSent = sendOtpSms($phone, $otp);
|
|
||||||
|
|
||||||
if (!$smsSent) {
|
|
||||||
error_log("WARN: Failed to send OTP SMS to phone hash: {$phoneHash}");
|
|
||||||
// Still return success to not reveal info, but log the issue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log for development (REMOVE IN PRODUCTION!)
|
|
||||||
if (env('APP_DEBUG', 'false') === 'true') {
|
|
||||||
error_log("DEV OTP for {$phone}: {$otp}");
|
|
||||||
}
|
|
||||||
|
|
||||||
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
|
|
||||||
|
|
||||||
// ─── SMS Helper ──────────────────────────────────────────
|
|
||||||
function sendOtpSms(string $phone, string $otp): bool
|
|
||||||
{
|
|
||||||
$smsProvider = env('SMS_PROVIDER', 'log'); // 'log', 'twilio', 'jordan_sms', 'custom'
|
|
||||||
|
|
||||||
$message = "رمز التحقق لتطبيق مُصادَق: {$otp}\nصالح لمدة 5 دقائق.";
|
|
||||||
|
|
||||||
switch ($smsProvider) {
|
|
||||||
case 'custom':
|
|
||||||
// Custom SMS API (your own provider)
|
|
||||||
$apiUrl = env('SMS_API_URL');
|
|
||||||
$apiKey = env('SMS_API_KEY');
|
|
||||||
if (!$apiUrl || !$apiKey) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$ch = curl_init($apiUrl);
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_POSTFIELDS => json_encode([
|
|
||||||
'to' => $phone,
|
|
||||||
'message' => $message,
|
|
||||||
'api_key' => $apiKey,
|
|
||||||
]),
|
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_TIMEOUT => 10,
|
|
||||||
]);
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
return $httpCode >= 200 && $httpCode < 300;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
error_log("SMS send error: " . $e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'log':
|
|
||||||
default:
|
|
||||||
// Development: just log the OTP
|
|
||||||
error_log("SMS OTP [{$phone}]: {$otp}");
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ if ($errors) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
|
$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);
|
$phoneHash = hash('sha256', $phone);
|
||||||
$deviceId = $data['device_id'] ?? '';
|
$deviceId = $data['device_id'] ?? '';
|
||||||
$deviceName = $data['device_name'] ?? 'Unknown Device';
|
$deviceName = $data['device_name'] ?? 'Unknown Device';
|
||||||
@@ -168,6 +175,7 @@ json_success([
|
|||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user['id'],
|
'id' => $user['id'],
|
||||||
'name' => $userName,
|
'name' => $userName,
|
||||||
|
'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
'tenant_id' => $user['tenant_id'],
|
'tenant_id' => $user['tenant_id'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,18 +36,29 @@ $expectedImages = (int)($data['expected_images'] ?? 0);
|
|||||||
|
|
||||||
// 2. Permission check
|
// 2. Permission check
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
|
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
|
||||||
$stmt->execute([$companyId, $tenantId]);
|
$stmt->execute([$companyId]);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
if (!$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);
|
json_error('الوصول مرفوض لهذه الشركة', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the actual tenant of the company
|
||||||
|
$targetTenantId = $company['tenant_id'];
|
||||||
|
|
||||||
// 3. Check quota (preview — don't increment yet)
|
// 3. Check quota (preview — don't increment yet)
|
||||||
try {
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
QuotaMiddleware::checkInvoiceQuota($tenantId);
|
try {
|
||||||
} catch (\Exception $e) {
|
QuotaMiddleware::checkInvoiceQuota($targetTenantId);
|
||||||
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
|
} catch (\Exception $e) {
|
||||||
|
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Generate batch ID
|
// 4. Generate batch ID
|
||||||
@@ -58,10 +69,10 @@ $stmt = $db->prepare("
|
|||||||
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
|
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
|
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId, $tenantId, $companyId, $userId, $expectedImages, $source]);
|
$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $expectedImages, $source]);
|
||||||
|
|
||||||
// 6. Create upload directory
|
// 6. Create upload directory
|
||||||
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
|
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
|
||||||
if (!is_dir($uploadDir)) {
|
if (!is_dir($uploadDir)) {
|
||||||
mkdir($uploadDir, 0755, true);
|
mkdir($uploadDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* POST /v1/batches/finalize
|
* POST /v1/batches/finalize
|
||||||
*
|
*
|
||||||
* Marks a batch as ready for processing.
|
* Marks a batch as ready for processing.
|
||||||
* Triggers background processing (or processes synchronously depending on setup).
|
* Sends instant response to mobile app, then processes in background via fastcgi_finish_request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
use App\Core\Security;
|
use App\Core\Security;
|
||||||
|
use App\Services\InvoiceProcessor;
|
||||||
|
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = AuthMiddleware::check();
|
||||||
$tenantId = $decoded['tenant_id'];
|
$tenantId = $decoded['tenant_id'];
|
||||||
@@ -28,14 +29,14 @@ $db = Database::getInstance();
|
|||||||
|
|
||||||
// 1. Verify batch
|
// 1. Verify batch
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT id, status, total_images
|
SELECT id, tenant_id, status, total_images
|
||||||
FROM invoice_batches
|
FROM invoice_batches
|
||||||
WHERE id = ? AND tenant_id = ? AND uploaded_by = ?
|
WHERE id = ? AND uploaded_by = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId, $tenantId, $userId]);
|
$stmt->execute([$batchId, $userId]);
|
||||||
$batch = $stmt->fetch();
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
if (!$batch) {
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
json_error('الدفعة غير موجودة', 404);
|
json_error('الدفعة غير موجودة', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +56,87 @@ $stmt = $db->prepare("
|
|||||||
");
|
");
|
||||||
$stmt->execute([$batchId]);
|
$stmt->execute([$batchId]);
|
||||||
|
|
||||||
// In a real production environment, you would dispatch a job to a queue worker here.
|
// 3. Send response IMMEDIATELY to mobile app
|
||||||
// For now, the queue worker is a cron job that checks the `invoice_processing_queue` table.
|
// 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);
|
||||||
|
|
||||||
json_success([
|
// Set headers
|
||||||
'batch_id' => $batchId,
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
'status' => 'processing',
|
header('Content-Length: ' . strlen($responsePayload));
|
||||||
'total_images' => $batch['total_images']
|
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";
|
||||||
@@ -27,14 +27,14 @@ $db = Database::getInstance();
|
|||||||
|
|
||||||
// 1. Get batch info
|
// 1. Get batch info
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT id, status, total_images, processed_images, failed_images, created_at, completed_at
|
SELECT id, tenant_id, status, total_images, processed_images, failed_images, created_at, completed_at
|
||||||
FROM invoice_batches
|
FROM invoice_batches
|
||||||
WHERE id = ? AND tenant_id = ?
|
WHERE id = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId, $tenantId]);
|
$stmt->execute([$batchId]);
|
||||||
$batch = $stmt->fetch();
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
if (!$batch) {
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
json_error('الدفعة غير موجودة', 404);
|
json_error('الدفعة غير موجودة', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,29 +25,32 @@ if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOA
|
|||||||
json_error("معرّف الدفعة وصورة الفاتورة مطلوبان (كود: {$uploadError})", 422);
|
json_error("معرّف الدفعة وصورة الفاتورة مطلوبان (كود: {$uploadError})", 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verify batch belongs to this tenant and is still uploading
|
// 2. Verify batch belongs to this user and tenant
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT id, company_id, status, total_images
|
SELECT id, tenant_id, company_id, status, total_images
|
||||||
FROM invoice_batches
|
FROM invoice_batches
|
||||||
WHERE id = ? AND tenant_id = ? AND uploaded_by = ?
|
WHERE id = ? AND uploaded_by = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId, $tenantId, $userId]);
|
$stmt->execute([$batchId, $userId]);
|
||||||
$batch = $stmt->fetch();
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
if (!$batch) {
|
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
|
||||||
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
|
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override tenantId with the actual batch's tenantId
|
||||||
|
$tenantId = $batch['tenant_id'];
|
||||||
|
|
||||||
if ($batch['status'] !== 'uploading') {
|
if ($batch['status'] !== 'uploading') {
|
||||||
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
|
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Validate file type
|
// 3. Validate file type
|
||||||
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
|
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
|
||||||
$mimeType = $_FILES['image']['type'];
|
$mimeType = $_FILES['image']['type'];
|
||||||
if (!in_array($mimeType, $allowedTypes)) {
|
if (!in_array($mimeType, $allowedTypes)) {
|
||||||
json_error('نوع الملف غير مدعوم. المسموح: JPEG, PNG, WebP, HEIC', 422);
|
json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Validate file size (max 10MB)
|
// 4. Validate file size (max 10MB)
|
||||||
|
|||||||
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);
|
||||||
@@ -61,5 +61,5 @@ try {
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("JoFotara Connection Error: " . $e->getMessage());
|
error_log("JoFotara Connection Error: " . $e->getMessage());
|
||||||
json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500);
|
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,11 @@
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Encryption;
|
use App\Core\Encryption;
|
||||||
use App\Core\Validator;
|
use App\Core\Validator;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
|
||||||
json_error('Unauthorized', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = input();
|
$data = input();
|
||||||
|
|
||||||
@@ -80,9 +79,16 @@ try {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
||||||
|
AuditLogger::log('company.created', 'company', null, null, [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'tin' => $data['tax_identification_number'],
|
||||||
|
], $decoded);
|
||||||
|
|
||||||
json_success(null, 'تم إنشاء الشركة بنجاح');
|
json_success(null, 'تم إنشاء الشركة بنجاح');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
error_log("[companies/create] Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Middleware\CompanyAccessMiddleware;
|
||||||
|
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
$companyId = input('id');
|
$companyId = input('id');
|
||||||
@@ -28,12 +31,13 @@ if (!$company) {
|
|||||||
json_error('الشركة غير موجودة', 404);
|
json_error('الشركة غير موجودة', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($decoded['role'] === 'admin' && $company['tenant_id'] !== $decoded['tenant_id']) {
|
// Verify tenant access (admin can only delete from their tenant)
|
||||||
json_error('ليس لديك صلاحية لحذف هذه الشركة', 403);
|
CompanyAccessMiddleware::check($companyId, $decoded);
|
||||||
}
|
|
||||||
|
|
||||||
// Soft Delete
|
// Soft Delete
|
||||||
$stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?");
|
$stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?");
|
||||||
$stmt->execute([$companyId]);
|
$stmt->execute([$companyId]);
|
||||||
|
|
||||||
|
AuditLogger::log('company.deleted', 'company', $companyId, null, null, $decoded);
|
||||||
|
|
||||||
json_success(null, 'تم حذف الشركة بنجاح');
|
json_success(null, 'تم حذف الشركة بنجاح');
|
||||||
|
|||||||
@@ -64,5 +64,5 @@ try {
|
|||||||
json_success($companies);
|
json_success($companies);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Companies List: ' . $e->getMessage(), 500);
|
safe_error($e, 'companies/index');
|
||||||
}
|
}
|
||||||
|
|||||||
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 '';
|
||||||
|
}
|
||||||
@@ -15,38 +15,67 @@ $companyId = $decoded['company_id'] ?? null;
|
|||||||
$role = $decoded['role'];
|
$role = $decoded['role'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Apply Filters based on Role
|
$stats = [
|
||||||
|
'role' => $role,
|
||||||
|
'invoices' => [
|
||||||
|
'total' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
'approved' => 0
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Fetch Invoice Stats
|
||||||
if ($role === 'super_admin') {
|
if ($role === 'super_admin') {
|
||||||
// No filters - see everything
|
|
||||||
$where = "WHERE 1=1";
|
$where = "WHERE 1=1";
|
||||||
$params = [];
|
$params = [];
|
||||||
|
} elseif ($role === 'accountant' || $role === 'viewer') {
|
||||||
|
$where = "WHERE tenant_id = ? AND company_id = ?";
|
||||||
|
$params = [$tenantId, $companyId];
|
||||||
} else {
|
} else {
|
||||||
// Tenant Users (Admin, Accountant, Employee): Filter by Tenant
|
// admin
|
||||||
$where = "WHERE tenant_id = ?";
|
$where = "WHERE tenant_id = ?";
|
||||||
$params = [$tenantId];
|
$params = [$tenantId];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch Stats
|
|
||||||
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where");
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where");
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$total = $stmt->fetchColumn();
|
$stats['invoices']['total'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'");
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'");
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$pending = $stmt->fetchColumn();
|
$stats['invoices']['pending'] = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
|
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$approved = $stmt->fetchColumn();
|
$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) {
|
} catch (\Exception $e) {
|
||||||
$total = 0;
|
// Return default zeroed stats on error
|
||||||
$pending = 0;
|
|
||||||
$approved = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
json_success([
|
json_success($stats);
|
||||||
'total' => $total,
|
|
||||||
'pending' => $pending,
|
|
||||||
'approved' => $approved
|
|
||||||
]);
|
|
||||||
|
|||||||
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, 'ملفك التنافسي');
|
||||||
@@ -5,9 +5,13 @@
|
|||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\JoFotara;
|
use App\Core\JoFotara;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Middleware\CompanyAccessMiddleware;
|
||||||
|
|
||||||
$decoded = AuthMiddleware::check();
|
// Only admin, accountant, and super_admin can approve. Viewers cannot.
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -111,8 +115,29 @@ try {
|
|||||||
'is_api_success' => $apiResponse['success']
|
'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) {
|
} catch (\Exception $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
error_log("JoFotara Approve Error: " . $e->getMessage());
|
error_log("JoFotara Approve Error: " . $e->getMessage());
|
||||||
json_error('خطأ غير متوقع: ' . $e->getMessage(), 500);
|
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);
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
@@ -19,15 +19,14 @@ function outputErrorImage($message) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from header OR query string
|
// Extract token from header OR query string using helper
|
||||||
$headers = getallheaders();
|
$token = input('token');
|
||||||
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
if (!$token) {
|
||||||
$token = '';
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||||
$token = $matches[1];
|
$token = $matches[1];
|
||||||
} elseif (isset($_GET['token'])) {
|
}
|
||||||
$token = $_GET['token'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$token) outputErrorImage('Forbidden: No token');
|
if (!$token) outputErrorImage('Forbidden: No token');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Invoices List Endpoint (Role-Based & Tenant-Aware)
|
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
@@ -16,26 +16,17 @@ $userId = $decoded['user_id'];
|
|||||||
$role = $decoded['role'];
|
$role = $decoded['role'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Build Query based on Role
|
$pagination = paginate_params(25, 100);
|
||||||
|
|
||||||
|
// 2. Build WHERE clause based on Role
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
if ($role === 'super_admin') {
|
if ($role === 'super_admin') {
|
||||||
// Super Admin sees ALL invoices
|
$where = '1=1';
|
||||||
$stmt = $db->query("
|
|
||||||
SELECT i.*, t.name as tenant_name, c.name as company_name
|
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN tenants t ON i.tenant_id = t.id
|
|
||||||
LEFT JOIN companies c ON i.company_id = c.id
|
|
||||||
ORDER BY i.created_at DESC
|
|
||||||
");
|
|
||||||
} elseif ($role === 'admin') {
|
} elseif ($role === 'admin') {
|
||||||
// Admin sees all invoices in THEIR tenant
|
$where = 'i.tenant_id = ?';
|
||||||
$stmt = $db->prepare("
|
$params = [$tenantId];
|
||||||
SELECT i.*, c.name as company_name
|
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN companies c ON i.company_id = c.id
|
|
||||||
WHERE i.tenant_id = ?
|
|
||||||
ORDER BY i.created_at DESC
|
|
||||||
");
|
|
||||||
$stmt->execute([$tenantId]);
|
|
||||||
} else {
|
} else {
|
||||||
// Accountant/Viewer: Filter by assigned companies
|
// Accountant/Viewer: Filter by assigned companies
|
||||||
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
||||||
@@ -43,26 +34,58 @@ try {
|
|||||||
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
|
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
if (empty($assignedCompanyIds)) {
|
if (empty($assignedCompanyIds)) {
|
||||||
json_success([]);
|
json_paginated([], 0, $pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
||||||
$stmt = $db->prepare("
|
$where = "i.company_id IN ($placeholders)";
|
||||||
SELECT i.*, c.name as company_name
|
$params = $assignedCompanyIds;
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN companies c ON i.company_id = c.id
|
|
||||||
WHERE i.company_id IN ($placeholders)
|
|
||||||
ORDER BY i.created_at DESC
|
|
||||||
");
|
|
||||||
$stmt->execute($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();
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
// 3. Decrypt sensitive fields for display (Robustly)
|
// 5. Decrypt sensitive fields
|
||||||
$dec = function($val) {
|
$dec = function($val) {
|
||||||
if (empty($val)) return '';
|
if (empty($val)) return '';
|
||||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
$result = Encryption::decrypt((string)$val);
|
||||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,12 +102,8 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($invoices)) {
|
json_paginated($invoices, $total, $pagination);
|
||||||
error_log("INVOICES LIST: No invoices found for role: $role, tenant_id: $tenantId");
|
|
||||||
}
|
|
||||||
|
|
||||||
json_success($invoices);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Invoices List: ' . $e->getMessage(), 500);
|
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', 'فشل تحديث الفاتورة.');
|
||||||
|
}
|
||||||
@@ -19,18 +19,20 @@ try {
|
|||||||
$tenantId = $decoded['tenant_id'];
|
$tenantId = $decoded['tenant_id'];
|
||||||
$userId = $decoded['user_id'];
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
// --- QUOTA CHECK ---
|
$allowedRoles = ['super_admin', 'admin', 'accountant', 'employee'];
|
||||||
QuotaMiddleware::checkInvoiceQuota($tenantId);
|
|
||||||
// -------------------
|
|
||||||
|
|
||||||
$db = Database::getInstance();
|
|
||||||
|
|
||||||
$allowedRoles = ['admin', 'accountant', 'employee'];
|
|
||||||
if (!in_array($decoded['role'], $allowedRoles)) {
|
if (!in_array($decoded['role'], $allowedRoles)) {
|
||||||
json_error('غير مصرح لك برفع الفواتير', 403);
|
json_error('غير مصرح لك برفع الفواتير', 403);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- QUOTA CHECK (skip for super_admin ONLY) ---
|
||||||
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
|
QuotaMiddleware::checkInvoiceQuota($tenantId);
|
||||||
|
}
|
||||||
|
// -------------------
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
// 2. Validate Request
|
// 2. Validate Request
|
||||||
// استخدام $_POST للتعامل الآمن مع multipart/form-data
|
// استخدام $_POST للتعامل الآمن مع multipart/form-data
|
||||||
$companyId = $_POST['company_id'] ?? null;
|
$companyId = $_POST['company_id'] ?? null;
|
||||||
@@ -46,14 +48,25 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Permission Check
|
// 3. Permission Check
|
||||||
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
|
if ($decoded['role'] === 'super_admin') {
|
||||||
$stmt->execute([$companyId, $tenantId]);
|
$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]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$stmt->fetch()) {
|
$company = $stmt->fetch();
|
||||||
|
if (!$company) {
|
||||||
json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403);
|
json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// لضمان حفظ الفاتورة في المكتب الصحيح إذا كان المرفوع سوبر أدمن
|
||||||
|
if ($decoded['role'] === 'super_admin') {
|
||||||
|
$tenantId = $company['tenant_id'];
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Handle File Upload
|
// 4. Handle File Upload
|
||||||
$tenantDir = STORAGE_PATH . '/invoices/' . $tenantId;
|
$tenantDir = STORAGE_PATH . '/invoices/' . $tenantId;
|
||||||
$companyDir = $tenantDir . '/' . $companyId;
|
$companyDir = $tenantDir . '/' . $companyId;
|
||||||
@@ -62,11 +75,12 @@ try {
|
|||||||
|
|
||||||
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
if (!mkdir($dir, 0777, true)) {
|
if (!mkdir($dir, 0755, true)) {
|
||||||
json_error('فشل في إنشاء مجلد التخزين: ' . $dir, 500);
|
error_log('Failed to create storage directory: ' . $dir);
|
||||||
|
json_error('فشل في تجهيز مساحة التخزين', 500);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
chmod($dir, 0777);
|
chmod($dir, 0755);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,118 +117,141 @@ try {
|
|||||||
// 6. Save Extracted Data
|
// 6. Save Extracted Data
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
|
|
||||||
$supplierTin = $extracted['supplier']['tin'] ?? '';
|
$extractedInvoices = $extracted['invoices'] ?? [$extracted];
|
||||||
$invoiceNum = $extracted['invoice_number'] ?? '';
|
$savedIds = [];
|
||||||
$invoiceDate = $extracted['invoice_date'] ?? '';
|
|
||||||
|
|
||||||
$invoiceHash = null;
|
foreach ($extractedInvoices as $inv) {
|
||||||
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
|
$supplierTin = $inv['supplier']['tin'] ?? '';
|
||||||
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
|
$invoiceNum = $inv['invoice_number'] ?? '';
|
||||||
$invoiceHash = hash('sha256', strtolower($rawHashString));
|
$invoiceDate = $inv['invoice_date'] ?? '';
|
||||||
|
|
||||||
$checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL");
|
$invoiceHash = null;
|
||||||
$checkStmt->execute([$companyId, $invoiceHash]);
|
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
|
||||||
if ($checkStmt->fetch()) {
|
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
|
||||||
$db->rollBack();
|
$invoiceHash = hash('sha256', strtolower($rawHashString));
|
||||||
json_error('هذه الفاتورة تم رفعها مسبقاً لهذه الشركة (رقم الفاتورة مكرر لنفس المورد والتاريخ).', 409);
|
|
||||||
exit;
|
$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));
|
$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;
|
||||||
|
|
||||||
// معالجة القيم الفارغة لمنع انهيار قاعدة البيانات (Strict Mode)
|
$stmt = $db->prepare("
|
||||||
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
|
INSERT INTO invoices (
|
||||||
$subtotal = is_numeric($extracted['subtotal'] ?? null) ? $extracted['subtotal'] : 0;
|
id, tenant_id, company_id, uploaded_by, original_file_path, status,
|
||||||
$tax = is_numeric($extracted['tax_amount'] ?? null) ? $extracted['tax_amount'] : 0;
|
invoice_number, invoice_date, invoice_type, invoice_category,
|
||||||
$disc = is_numeric($extracted['discount_total'] ?? null) ? $extracted['discount_total'] : 0;
|
supplier_tin, supplier_name, supplier_address,
|
||||||
$total = is_numeric($extracted['grand_total'] ?? null) ? $extracted['grand_total'] : 0;
|
buyer_tin, buyer_name, buyer_national_id,
|
||||||
|
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
||||||
$stmt = $db->prepare("
|
invoice_hash, validation_warnings,
|
||||||
INSERT INTO invoices (
|
created_at
|
||||||
id, tenant_id, company_id, uploaded_by, original_file_path, status,
|
) VALUES (
|
||||||
invoice_number, invoice_date, invoice_type, invoice_category,
|
:id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted',
|
||||||
supplier_tin, supplier_name, supplier_address,
|
:num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid,
|
||||||
buyer_tin, buyer_name, buyer_national_id,
|
:sub, :tax, :disc, :total, :cur,
|
||||||
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
:hash, :warnings,
|
||||||
invoice_hash, validation_warnings,
|
NOW()
|
||||||
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($extracted['invoice_type']) ? $extracted['invoice_type'] : 'cash',
|
|
||||||
'cat' => !empty($extracted['invoice_category']) ? $extracted['invoice_category'] : 'simplified',
|
|
||||||
's_tin' => Encryption::encrypt($supplierTin),
|
|
||||||
's_name' => Encryption::encrypt($extracted['supplier']['name'] ?? ''),
|
|
||||||
's_addr' => Encryption::encrypt($extracted['supplier']['address'] ?? ''),
|
|
||||||
'b_tin' => Encryption::encrypt($extracted['buyer']['tin'] ?? ''),
|
|
||||||
'b_name' => Encryption::encrypt($extracted['buyer']['name'] ?? ''),
|
|
||||||
'b_nid' => Encryption::encrypt($extracted['buyer']['national_id'] ?? ''),
|
|
||||||
'sub' => $subtotal,
|
|
||||||
'tax' => $tax,
|
|
||||||
'disc' => $disc,
|
|
||||||
'total' => $total,
|
|
||||||
'cur' => !empty($extracted['currency_code']) ? $extracted['currency_code'] : 'JOD',
|
|
||||||
'hash' => $invoiceHash,
|
|
||||||
'warnings' => !empty($extracted['validation_warnings']) ? json_encode($extracted['validation_warnings']) : null
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Save Line Items
|
|
||||||
if (!empty($extracted['lines']) && is_array($extracted['lines'])) {
|
|
||||||
$lineStmt = $db->prepare("
|
|
||||||
INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
");
|
");
|
||||||
foreach ($extracted['lines'] as $index => $item) {
|
|
||||||
$lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
$stmt->execute([
|
||||||
$lineStmt->execute([
|
'id' => $invoiceId,
|
||||||
$lineId,
|
'tenant_id' => $tenantId,
|
||||||
$invoiceId,
|
'company_id' => $companyId,
|
||||||
$item['line_number'] ?? ($index + 1),
|
'uploaded_by' => $userId,
|
||||||
$item['description'] ?? 'بدون وصف',
|
'path' => $targetFile,
|
||||||
is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1,
|
'num' => !empty($invoiceNum) ? $invoiceNum : null,
|
||||||
is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0,
|
'date' => $validDate,
|
||||||
is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16,
|
'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash',
|
||||||
is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0
|
'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();
|
$db->commit();
|
||||||
|
|
||||||
// --- INCREMENT QUOTA ---
|
if (empty($savedIds)) {
|
||||||
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
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');
|
||||||
// -----------------------
|
// -----------------------
|
||||||
|
|
||||||
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
|
$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;
|
exit;
|
||||||
|
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
if (isset($db) && $db->inTransaction()) {
|
if (isset($db) && $db->inTransaction()) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
}
|
}
|
||||||
error_log("Database Error: " . $e->getMessage());
|
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
json_error('حدث خطأ في قاعدة البيانات: ' . $e->getMessage(), 500);
|
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
exit;
|
exit;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if (isset($db) && $db->inTransaction()) {
|
if (isset($db) && $db->inTransaction()) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
}
|
}
|
||||||
error_log("Critical Error: " . $e->getMessage() . " on line " . $e->getLine());
|
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
json_error('خطأ برمجي حرج: ' . $e->getMessage() . ' في السطر ' . $e->getLine(), 500);
|
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
|
||||||
exit;
|
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("خطأ في النظام");
|
||||||
|
}
|
||||||
@@ -91,8 +91,13 @@ try {
|
|||||||
$invoice['jofotara'] = null;
|
$invoice['jofotara'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Build the secure file URL using the invoice ID (file.php fetches path from DB)
|
// 5. Build the secure file URL with token (for Image.network compatibility)
|
||||||
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . urlencode($id);
|
$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
|
// 6. Include local QR code from invoices table if available
|
||||||
// (This is used as a fallback in shell.php if jofotara object is missing)
|
// (This is used as a fallback in shell.php if jofotara object is missing)
|
||||||
|
|||||||
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' => 'خطأ أثناء تأكيد الدفعة'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,5 +91,5 @@ try {
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
error_log("Subscription Assign Error: " . $e->getMessage());
|
error_log("Subscription Assign Error: " . $e->getMessage());
|
||||||
json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500);
|
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ $db = Database::getInstance();
|
|||||||
try {
|
try {
|
||||||
$stmt = $db->query("
|
$stmt = $db->query("
|
||||||
SELECT id, name_ar, name_en, max_companies, max_invoices_month, max_users,
|
SELECT id, name_ar, name_en, max_companies, max_invoices_month, max_users,
|
||||||
price_jod, ai_features, jofotara_enabled, sort_order
|
price_jod, price_annual_jod, price_monthly_jod, ai_features, jofotara_enabled, sort_order
|
||||||
FROM subscription_plans
|
FROM subscription_plans
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
ORDER BY sort_order ASC
|
ORDER BY sort_order ASC
|
||||||
@@ -36,6 +36,8 @@ try {
|
|||||||
$plan['max_invoices_month'] = (int)$plan['max_invoices_month'];
|
$plan['max_invoices_month'] = (int)$plan['max_invoices_month'];
|
||||||
$plan['max_users'] = (int)$plan['max_users'];
|
$plan['max_users'] = (int)$plan['max_users'];
|
||||||
$plan['price_jod'] = (float)$plan['price_jod'];
|
$plan['price_jod'] = (float)$plan['price_jod'];
|
||||||
|
$plan['price_annual_jod'] = (float)$plan['price_annual_jod'];
|
||||||
|
$plan['price_monthly_jod'] = (float)$plan['price_monthly_jod'];
|
||||||
$plan['ai_features'] = (bool)$plan['ai_features'];
|
$plan['ai_features'] = (bool)$plan['ai_features'];
|
||||||
$plan['jofotara_enabled'] = (bool)$plan['jofotara_enabled'];
|
$plan['jofotara_enabled'] = (bool)$plan['jofotara_enabled'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ $data = input();
|
|||||||
$errors = Validator::validate($data, [
|
$errors = Validator::validate($data, [
|
||||||
'name' => 'required',
|
'name' => 'required',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
|
'phone' => 'required',
|
||||||
'manager_name' => 'required',
|
'manager_name' => 'required',
|
||||||
'manager_email' => 'required|email',
|
|
||||||
'manager_password' => 'required'
|
'manager_password' => 'required'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -43,12 +43,23 @@ try {
|
|||||||
$encryptedTenantName = \App\Core\Encryption::encrypt($data['name']);
|
$encryptedTenantName = \App\Core\Encryption::encrypt($data['name']);
|
||||||
$encryptedTenantEmail = \App\Core\Encryption::encrypt($data['email']);
|
$encryptedTenantEmail = \App\Core\Encryption::encrypt($data['email']);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryptedPhone = \App\Core\Encryption::encrypt($phone);
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, phone, status, created_at) VALUES (?, ?, ?, ?, 'active', NOW())");
|
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, phone, status, created_at) VALUES (?, ?, ?, ?, 'active', NOW())");
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$encryptedTenantName,
|
$encryptedTenantName,
|
||||||
$encryptedTenantEmail,
|
$encryptedTenantEmail,
|
||||||
$data['phone'] ?? null
|
$phone
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generate User UUID
|
// Generate User UUID
|
||||||
@@ -60,17 +71,19 @@ try {
|
|||||||
|
|
||||||
// Encrypt sensitive user data
|
// Encrypt sensitive user data
|
||||||
$encryptedName = \App\Core\Encryption::encrypt($data['manager_name']);
|
$encryptedName = \App\Core\Encryption::encrypt($data['manager_name']);
|
||||||
$encryptedEmail = \App\Core\Encryption::encrypt($data['manager_email']);
|
$encryptedEmail = \App\Core\Encryption::encrypt($data['email']);
|
||||||
$emailHash = hash('sha256', strtolower($data['manager_email']));
|
$emailHash = hash('sha256', strtolower($data['email']));
|
||||||
|
|
||||||
// 2. Create Initial Manager (Admin) for this Tenant
|
// 2. Create Initial Manager (Admin) for this Tenant
|
||||||
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, 'admin', NOW())");
|
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, phone, phone_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', NOW())");
|
||||||
$stmtUser->execute([
|
$stmtUser->execute([
|
||||||
$userId,
|
$userId,
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$encryptedName,
|
$encryptedName,
|
||||||
$encryptedEmail,
|
$encryptedEmail,
|
||||||
$emailHash,
|
$emailHash,
|
||||||
|
$encryptedPhone,
|
||||||
|
$phoneHash,
|
||||||
password_hash($data['manager_password'], PASSWORD_DEFAULT)
|
password_hash($data['manager_password'], PASSWORD_DEFAULT)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -78,6 +91,6 @@ try {
|
|||||||
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
|
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
app/modules_app/tenants/delete.php
Normal file
40
app/modules_app/tenants/delete.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Delete Tenant
|
||||||
|
* POST /v1/tenants/delete
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin']);
|
||||||
|
|
||||||
|
$data = input();
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) json_error('معرّف المكتب مطلوب', 422);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Check if tenant exists
|
||||||
|
$stmt = $db->prepare("SELECT * FROM tenants WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$tenant = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$tenant) json_error('المكتب غير موجود', 404);
|
||||||
|
|
||||||
|
// Check for linked users
|
||||||
|
$stmtUsers = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ?");
|
||||||
|
$stmtUsers->execute([$id]);
|
||||||
|
$userCount = $stmtUsers->fetchColumn();
|
||||||
|
|
||||||
|
if ($userCount > 0) {
|
||||||
|
json_error("لا يمكن حذف المكتب — يوجد $userCount مستخدم مرتبط به. احذف المستخدمين أولاً.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
$db->prepare("DELETE FROM tenants WHERE id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
AuditLogger::log('tenant.deleted', 'tenant', $id, ['name' => $tenant['name']], null, $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم حذف المكتب المحاسبي بنجاح');
|
||||||
@@ -18,22 +18,29 @@ try {
|
|||||||
$stmt = $db->query("
|
$stmt = $db->query("
|
||||||
SELECT t.id, t.name, t.email, t.phone, t.status, t.created_at,
|
SELECT t.id, t.name, t.email, t.phone, t.status, t.created_at,
|
||||||
(SELECT COUNT(*) FROM companies WHERE tenant_id = t.id) as companies_count,
|
(SELECT COUNT(*) FROM companies WHERE tenant_id = t.id) as companies_count,
|
||||||
|
(SELECT COUNT(*) FROM users WHERE tenant_id = t.id) as users_count,
|
||||||
(SELECT COUNT(*) FROM invoices WHERE tenant_id = t.id) as invoices_count
|
(SELECT COUNT(*) FROM invoices WHERE tenant_id = t.id) as invoices_count
|
||||||
FROM tenants t
|
FROM tenants t
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
");
|
");
|
||||||
$tenants = $stmt->fetchAll();
|
$tenants = $stmt->fetchAll();
|
||||||
|
|
||||||
foreach ($tenants as &$t) {
|
$dec = function($val) {
|
||||||
$decName = \App\Core\Encryption::decrypt($t['name']);
|
if (empty($val)) return '';
|
||||||
$t['name'] = $decName !== false ? $decName : $t['name'];
|
$result = \App\Core\Encryption::decrypt((string)$val);
|
||||||
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
|
};
|
||||||
|
|
||||||
$decEmail = \App\Core\Encryption::decrypt($t['email']);
|
foreach ($tenants as &$t) {
|
||||||
$t['email'] = $decEmail !== false ? $decEmail : $t['email'];
|
$t['name'] = $dec($t['name']);
|
||||||
|
$t['email'] = $dec($t['email']);
|
||||||
|
if (!empty($t['phone'])) {
|
||||||
|
$t['phone'] = $dec($t['phone']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
json_success($tenants);
|
json_success($tenants);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Tenants List: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,5 @@ try {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('Stats Error: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/stats');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,5 @@ try {
|
|||||||
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
|
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Encryption;
|
use App\Core\Encryption;
|
||||||
use App\Core\Validator;
|
use App\Core\Validator;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
// 1. Auth Check (Only super_admin or admin can create users)
|
// 1. Auth + Role Check (Only super_admin or admin can create users)
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
|
||||||
json_error('Unauthorized', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = input();
|
$data = input();
|
||||||
|
|
||||||
@@ -31,7 +30,8 @@ if (!in_array($data['role'] ?? '', $allowedRoles, true)) {
|
|||||||
$errors = Validator::validate($data, [
|
$errors = Validator::validate($data, [
|
||||||
'name' => 'required',
|
'name' => 'required',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
'password' => 'required',
|
'phone' => 'required',
|
||||||
|
'password' => 'required|strong_password',
|
||||||
'role' => 'required'
|
'role' => 'required'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -46,6 +46,17 @@ $encryptedName = Encryption::encrypt($data['name']);
|
|||||||
$encryptedEmail = Encryption::encrypt($data['email']);
|
$encryptedEmail = Encryption::encrypt($data['email']);
|
||||||
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
|
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryptedPhone = Encryption::encrypt($phone);
|
||||||
|
$phoneHash = hash('sha256', $phone);
|
||||||
|
|
||||||
// 3. Determine Tenant ID
|
// 3. Determine Tenant ID
|
||||||
$tenantId = null;
|
$tenantId = null;
|
||||||
if ($decoded['role'] === 'super_admin') {
|
if ($decoded['role'] === 'super_admin') {
|
||||||
@@ -63,19 +74,27 @@ if ($decoded['role'] === 'super_admin') {
|
|||||||
|
|
||||||
// 4. Save to Database
|
// 4. Save to Database
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, phone, phone_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
\App\Core\Database::generateUuid(),
|
\App\Core\Database::generateUuid(),
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$encryptedName,
|
$encryptedName,
|
||||||
$encryptedEmail,
|
$encryptedEmail,
|
||||||
$emailHash,
|
$emailHash,
|
||||||
|
$encryptedPhone,
|
||||||
|
$phoneHash,
|
||||||
password_hash($data['password'], PASSWORD_DEFAULT),
|
password_hash($data['password'], PASSWORD_DEFAULT),
|
||||||
$data['role'],
|
$data['role'],
|
||||||
date('Y-m-d H:i:s')
|
date('Y-m-d H:i:s')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
json_success(null, 'تم إضافة المستخدم بنجاح');
|
json_success(null, 'تم إضافة المستخدم بنجاح');
|
||||||
|
|
||||||
|
AuditLogger::log('user.created', 'user', null, null, [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'role' => $data['role'],
|
||||||
|
], $decoded);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if (str_contains($e->getMessage(), 'Duplicate entry')) {
|
if (str_contains($e->getMessage(), 'Duplicate entry')) {
|
||||||
json_error('البريد الإلكتروني مسجل مسبقاً', 409);
|
json_error('البريد الإلكتروني مسجل مسبقاً', 409);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
// 1. Auth Check
|
// 1. Auth + Role Check
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
$currentUserId = $decoded['user_id'];
|
$currentUserId = $decoded['user_id'];
|
||||||
@@ -52,4 +54,8 @@ if ($currentUserRole === 'super_admin') {
|
|||||||
$stmt = $db->prepare("UPDATE users SET deleted_at = NOW(), is_active = 0 WHERE id = ?");
|
$stmt = $db->prepare("UPDATE users SET deleted_at = NOW(), is_active = 0 WHERE id = ?");
|
||||||
$stmt->execute([$targetUserId]);
|
$stmt->execute([$targetUserId]);
|
||||||
|
|
||||||
|
AuditLogger::log('user.deleted', 'user', $targetUserId, [
|
||||||
|
'role' => $targetUser['role'],
|
||||||
|
], null, $decoded);
|
||||||
|
|
||||||
json_success(null, 'تم حذف المستخدم بنجاح');
|
json_success(null, 'تم حذف المستخدم بنجاح');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Users List Endpoint (Role-Based & Tenant-Aware)
|
* Users List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
@@ -14,55 +14,74 @@ $db = Database::getInstance();
|
|||||||
$role = $decoded['role'];
|
$role = $decoded['role'];
|
||||||
$tenantId = $decoded['tenant_id'] ?? null;
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
if ($role !== 'super_admin' && $role !== 'admin') {
|
||||||
|
json_error('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Build Query based on Role
|
$pagination = paginate_params(25, 100);
|
||||||
|
|
||||||
|
// 2. Build WHERE clause based on Role
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
if ($role === 'super_admin') {
|
if ($role === 'super_admin') {
|
||||||
// Super Admin sees ALL users from ALL tenants
|
$where = '1=1';
|
||||||
$stmt = $db->query("
|
|
||||||
SELECT u.id, u.name, u.email, u.role, u.is_active, u.created_at, t.name as tenant_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN tenants t ON u.tenant_id = t.id
|
|
||||||
ORDER BY u.created_at DESC
|
|
||||||
");
|
|
||||||
} elseif ($role === 'admin') {
|
|
||||||
// Admin sees only users in THEIR tenant (Accounting Office)
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
SELECT u.id, u.name, u.email, u.role, u.is_active, u.created_at, t.name as tenant_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN tenants t ON u.tenant_id = t.id
|
|
||||||
WHERE u.tenant_id = ?
|
|
||||||
ORDER BY u.created_at DESC
|
|
||||||
");
|
|
||||||
$stmt->execute([$tenantId]);
|
|
||||||
} else {
|
} else {
|
||||||
// Other roles shouldn't see user list
|
$where = 'u.tenant_id = ?';
|
||||||
json_error('Unauthorized', 403);
|
$params = [$tenantId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional filters
|
||||||
|
$roleFilter = $_GET['role'] ?? null;
|
||||||
|
$activeFilter = $_GET['is_active'] ?? null;
|
||||||
|
|
||||||
|
if ($roleFilter) {
|
||||||
|
$where .= ' AND u.role = ?';
|
||||||
|
$params[] = $roleFilter;
|
||||||
|
}
|
||||||
|
if ($activeFilter !== null && $activeFilter !== '') {
|
||||||
|
$where .= ' AND u.is_active = ?';
|
||||||
|
$params[] = (int)$activeFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Count total
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM users u WHERE $where");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 4. Fetch page
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN tenants t ON u.tenant_id = t.id
|
||||||
|
WHERE $where
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
$users = $stmt->fetchAll();
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
// 3. Decrypt data and format
|
// 5. Decrypt data
|
||||||
$dec = function($val) {
|
$dec = function($val) {
|
||||||
if (empty($val)) return '';
|
if (empty($val)) return '';
|
||||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
$result = Encryption::decrypt((string)$val);
|
||||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach ($users as &$user) {
|
foreach ($users as &$user) {
|
||||||
$user['name'] = $dec($user['name']);
|
$user['name'] = $dec($user['name']);
|
||||||
$user['email'] = $dec($user['email']);
|
$user['email'] = $dec($user['email']);
|
||||||
|
if (!empty($user['phone'])) {
|
||||||
|
$user['phone'] = $dec($user['phone']);
|
||||||
|
}
|
||||||
if (!empty($user['tenant_name'])) {
|
if (!empty($user['tenant_name'])) {
|
||||||
$user['tenant_name'] = $dec($user['tenant_name']);
|
$user['tenant_name'] = $dec($user['tenant_name']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($users)) {
|
json_paginated($users, $total, $pagination);
|
||||||
error_log("USERS LIST: No users found for role: $role, tenant_id: $tenantId");
|
|
||||||
}
|
|
||||||
|
|
||||||
json_success($users);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Users List: ' . $e->getMessage(), 500);
|
safe_error($e, 'users/index');
|
||||||
}
|
}
|
||||||
|
|||||||
81
app/modules_app/users/update.php
Normal file
81
app/modules_app/users/update.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Update User Endpoint
|
||||||
|
* POST /v1/users/update
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
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 users WHERE id = ?"
|
||||||
|
: "SELECT * FROM users WHERE id = ? AND tenant_id = ?";
|
||||||
|
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) json_error('المستخدم غير موجود', 404);
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = ?';
|
||||||
|
$values[] = \App\Core\Encryption::encrypt($data['name']);
|
||||||
|
}
|
||||||
|
if (isset($data['email'])) {
|
||||||
|
$fields[] = 'email = ?';
|
||||||
|
$values[] = \App\Core\Encryption::encrypt($data['email']);
|
||||||
|
$fields[] = 'email_hash = ?';
|
||||||
|
$values[] = hash('sha256', strtolower($data['email']));
|
||||||
|
}
|
||||||
|
if (isset($data['role'])) {
|
||||||
|
if ($role !== 'super_admin' && $data['role'] === 'super_admin') {
|
||||||
|
json_error('لا يمكنك منح صلاحية مدير النظام', 403);
|
||||||
|
}
|
||||||
|
$fields[] = 'role = ?';
|
||||||
|
$values[] = $data['role'];
|
||||||
|
}
|
||||||
|
if (isset($data['phone'])) {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields[] = 'phone = ?';
|
||||||
|
$values[] = \App\Core\Encryption::encrypt($phone);
|
||||||
|
$fields[] = 'phone_hash = ?';
|
||||||
|
$values[] = hash('sha256', $phone);
|
||||||
|
}
|
||||||
|
if (isset($data['is_active'])) {
|
||||||
|
$fields[] = 'is_active = ?';
|
||||||
|
$values[] = (int) $data['is_active'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) json_error('لا توجد بيانات للتحديث', 422);
|
||||||
|
|
||||||
|
$fields[] = 'updated_at = NOW()';
|
||||||
|
$values[] = $id;
|
||||||
|
|
||||||
|
$sql = "UPDATE users SET " . implode(', ', $fields) . " WHERE id = ?";
|
||||||
|
$db->prepare($sql)->execute($values);
|
||||||
|
|
||||||
|
AuditLogger::log('user.updated', 'user', $id, null, ['fields' => array_keys($data)], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم تحديث بيانات المستخدم بنجاح');
|
||||||
106
app/modules_app/voice/grok_intent.php
Normal file
106
app/modules_app/voice/grok_intent.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Voice Parse Intent Proxy Endpoint (Grok Variant - xAI)
|
||||||
|
* POST /v1/voice/parse-intent-grok
|
||||||
|
*
|
||||||
|
* Proxies transcribed text to Grok (xAI) to extract intent and parameters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RateLimitMiddleware;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Core\Validator;
|
||||||
|
|
||||||
|
// Rate limit: 20 per minute
|
||||||
|
RateLimitMiddleware::check(20, 60);
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, ['text' => 'required']);
|
||||||
|
if ($errors) {
|
||||||
|
json_error('النص مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = env('XAI_API_KEY'); // Ensure this is set in .env
|
||||||
|
if (!$apiKey) {
|
||||||
|
json_error('xAI API Key غير متوفر', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $data['text'];
|
||||||
|
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
أنت محلل أوامر لنظام مُصادَق للفوترة الأردني.
|
||||||
|
استخرج النية والمعاملات من النص وأرجع JSON فقط.
|
||||||
|
|
||||||
|
الأوامر المتاحة:
|
||||||
|
- list_invoices: { company?: string, from?: date, to?: date, status?: string }
|
||||||
|
- check_quota: {}
|
||||||
|
- open_scanner: { company?: string }
|
||||||
|
- search_invoice: { amount?: number, company?: string, number?: string }
|
||||||
|
- get_report: { type: "tax"|"monthly", period?: string }
|
||||||
|
- check_status: { invoice_id?: string, company?: string }
|
||||||
|
- export_pdf: { invoice_id?: string, company?: string }
|
||||||
|
- navigate: { screen: string }
|
||||||
|
|
||||||
|
أرجع JSON بهذا التنسيق:
|
||||||
|
{
|
||||||
|
"action": "...",
|
||||||
|
"params": {...},
|
||||||
|
"confirmation": "نص قصير تأكيد بالعامية الأردنية أو الفصحى المبسطة"
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => 'grok-2-1212', // Updated to current xAI model name
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
|
['role' => 'user', 'content' => $text]
|
||||||
|
],
|
||||||
|
'response_format' => ['type' => 'json_object'],
|
||||||
|
'temperature' => 0.2
|
||||||
|
];
|
||||||
|
|
||||||
|
$url = "https://api.x.ai/v1/chat/completions";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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',
|
||||||
|
'Authorization: Bearer ' . $apiKey
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("Grok Error: $response | $error");
|
||||||
|
json_error('فشل في تحليل الأمر بواسطة Grok. تأكد من صحة مفتاح API وصلاحية الحساب.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$respData = json_decode($response, true);
|
||||||
|
if (!isset($respData['choices'][0]['message']['content'])) {
|
||||||
|
json_error('رد غير متوقع من Grok AI', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonText = $respData['choices'][0]['message']['content'];
|
||||||
|
$parsed = json_decode($jsonText, true);
|
||||||
|
|
||||||
|
if (!$parsed) {
|
||||||
|
json_error('فشل في تحليل الرد كـ JSON', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success($parsed, 'تم تحليل الأمر بواسطة Grok');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("Voice Intent Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ فني أثناء تحليل الأمر صوتياً.', 500);
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Voice Transcribe Proxy Endpoint
|
* Voice Command Endpoint (Gemini Audio → Intent → Internal Execution)
|
||||||
* POST /v1/voice/transcribe
|
* POST /v1/voice/transcribe
|
||||||
*
|
*
|
||||||
* Proxies audio file to Groq STT (Whisper) safely keeping API keys on backend.
|
* This endpoint:
|
||||||
|
* 1) receives audio from mobile,
|
||||||
|
* 2) sends it directly to Gemini (no Groq),
|
||||||
|
* 3) extracts intent JSON,
|
||||||
|
* 4) executes supported actions internally,
|
||||||
|
* 5) returns intent + execution result to Flutter.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
set_time_limit(90);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\QuotaMiddleware;
|
||||||
use App\Middleware\RateLimitMiddleware;
|
use App\Middleware\RateLimitMiddleware;
|
||||||
|
|
||||||
// Rate limit: 20 per minute
|
// Rate limit: 15 voice requests per minute
|
||||||
RateLimitMiddleware::check(20, 60);
|
RateLimitMiddleware::check(15, 60);
|
||||||
|
|
||||||
$decoded = AuthMiddleware::check();
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
@@ -20,42 +30,614 @@ if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
|
|||||||
json_error('ملف الصوت مطلوب', 422);
|
json_error('ملف الصوت مطلوب', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$apiKey = env('GROQ_API_KEY');
|
$audio = $_FILES['audio'];
|
||||||
if (!$apiKey) {
|
$maxBytes = 10 * 1024 * 1024; // 10MB inline-safe
|
||||||
json_error('Groq API Key غير متوفر', 500);
|
if (($audio['size'] ?? 0) <= 0) {
|
||||||
|
json_error('ملف الصوت فارغ', 422);
|
||||||
|
}
|
||||||
|
if (($audio['size'] ?? 0) > $maxBytes) {
|
||||||
|
json_error('حجم ملف الصوت أكبر من الحد المسموح (10MB)', 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure it's a valid audio file (basic check)
|
$geminiApiKey = env('GEMINI_API_KEY');
|
||||||
$tmpPath = $_FILES['audio']['tmp_name'];
|
if (!$geminiApiKey) {
|
||||||
|
json_error('Gemini API Key غير متوفر', 500);
|
||||||
$cfile = curl_file_create($tmpPath, $_FILES['audio']['type'], $_FILES['audio']['name']);
|
|
||||||
|
|
||||||
$postData = [
|
|
||||||
'file' => $cfile,
|
|
||||||
'model' => 'whisper-large-v3',
|
|
||||||
'language' => 'ar',
|
|
||||||
'response_format' => 'json'
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init('https://api.groq.com/openai/v1/audio/transcriptions');
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_POSTFIELDS => $postData,
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_HTTPHEADER => [
|
|
||||||
'Authorization: Bearer ' . $apiKey
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$error = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($httpCode !== 200) {
|
|
||||||
error_log("Groq Error: $response | $error");
|
|
||||||
json_error('فشل في تحويل الصوت إلى نص', 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($response, true);
|
$tmpPath = $audio['tmp_name'];
|
||||||
json_success(['text' => $data['text'] ?? ''], 'تم التحويل بنجاح');
|
$rawAudio = @file_get_contents($tmpPath);
|
||||||
|
if ($rawAudio === false) {
|
||||||
|
json_error('فشل في قراءة ملف الصوت', 500);
|
||||||
|
}
|
||||||
|
$base64Audio = base64_encode($rawAudio);
|
||||||
|
|
||||||
|
$mimeType = detectAudioMimeType(
|
||||||
|
$tmpPath,
|
||||||
|
(string)($audio['type'] ?? ''),
|
||||||
|
(string)($audio['name'] ?? '')
|
||||||
|
);
|
||||||
|
$intent = extractIntentFromAudio($base64Audio, $mimeType, $geminiApiKey);
|
||||||
|
$execution = executeVoiceAction($decoded, $intent);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'intent' => $intent,
|
||||||
|
'execution' => $execution,
|
||||||
|
], 'تم تحليل الأمر الصوتي وتنفيذه');
|
||||||
|
|
||||||
|
function detectAudioMimeType(string $path, string $fallback, string $fileName = ''): string
|
||||||
|
{
|
||||||
|
$allowed = [
|
||||||
|
'audio/mp3',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/wav',
|
||||||
|
'audio/x-wav',
|
||||||
|
'audio/aiff',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/flac',
|
||||||
|
];
|
||||||
|
|
||||||
|
$detected = $fallback;
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
if ($finfo !== false) {
|
||||||
|
$probe = finfo_file($finfo, $path);
|
||||||
|
if (is_string($probe) && $probe !== '') {
|
||||||
|
$detected = $probe;
|
||||||
|
}
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($detected === 'audio/x-wav' || str_ends_with(strtolower($fileName), '.wav')) {
|
||||||
|
return 'audio/wav';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Flutter recorder now sends WAV. If the server cannot detect the part
|
||||||
|
// MIME type, use a Gemini-supported fallback instead of m4a/mp4.
|
||||||
|
return in_array($detected, $allowed, true) ? $detected : 'audio/wav';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIntentFromAudio(string $base64Audio, string $mimeType, string $apiKey): array
|
||||||
|
{
|
||||||
|
$model = env('GEMINI_MODEL', 'gemini-flash-lite-latest');
|
||||||
|
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
أنت مساعد أوامر صوتية عربي لمنصة "مُصادَق" للفوترة الأردنية.
|
||||||
|
المطلوب:
|
||||||
|
1) افهم محتوى الصوت بالعربية (حتى لو كان لهجة).
|
||||||
|
2) استخرج الأمر المناسب فقط من القائمة المحددة.
|
||||||
|
3) أرجع JSON صالح فقط بدون أي نص إضافي.
|
||||||
|
|
||||||
|
الأوامر المسموحة:
|
||||||
|
- list_invoices: { company?: string, from?: string, to?: string, status?: string, limit?: number }
|
||||||
|
- check_quota: {}
|
||||||
|
- open_scanner: { company?: string }
|
||||||
|
- search_invoice: { amount?: number, company?: string, number?: string }
|
||||||
|
- get_report: { type?: "tax"|"monthly", period?: string }
|
||||||
|
- check_status: { invoice_id?: string, invoice_number?: string, company?: string }
|
||||||
|
- export_pdf: { invoice_id?: string, company?: string }
|
||||||
|
- navigate: { screen: string }
|
||||||
|
|
||||||
|
أعد النتيجة بهذا الشكل حرفيًا:
|
||||||
|
{
|
||||||
|
"action": "list_invoices|check_quota|open_scanner|search_invoice|get_report|check_status|export_pdf|navigate",
|
||||||
|
"params": {},
|
||||||
|
"confirmation": "جملة عربية قصيرة مناسبة للمستخدم",
|
||||||
|
"transcript": "تفريغ تقريبي قصير لما تم فهمه"
|
||||||
|
}
|
||||||
|
|
||||||
|
إذا كان الصوت غير واضح تمامًا:
|
||||||
|
{
|
||||||
|
"action": "navigate",
|
||||||
|
"params": {"screen":"dashboard"},
|
||||||
|
"confirmation": "الصوت غير واضح، احكي الطلب مرة ثانية بشكل أوضح",
|
||||||
|
"transcript": ""
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => [
|
||||||
|
[
|
||||||
|
'parts' => [
|
||||||
|
['text' => 'حلّل هذا التسجيل الصوتي واستخرج أمر النظام بصيغة JSON فقط.'],
|
||||||
|
[
|
||||||
|
'inline_data' => [
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'data' => $base64Audio,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'systemInstruction' => [
|
||||||
|
'parts' => [
|
||||||
|
['text' => $systemPrompt],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'generationConfig' => [
|
||||||
|
'responseMimeType' => 'application/json',
|
||||||
|
'temperature' => 0.1,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = callGeminiGenerateContent($model, $payload, $apiKey);
|
||||||
|
|
||||||
|
// Some Gemini model/API combinations reject JSON mode for multimodal audio.
|
||||||
|
// Retry once with prompt-only JSON enforcement before failing the request.
|
||||||
|
if ($result['http_code'] !== 200 || !$result['body']) {
|
||||||
|
$fallbackPayload = $payload;
|
||||||
|
unset($fallbackPayload['generationConfig']['responseMimeType']);
|
||||||
|
$result = callGeminiGenerateContent($model, $fallbackPayload, $apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result['http_code'] !== 200 || !$result['body']) {
|
||||||
|
$geminiError = parseGeminiError($result['body']);
|
||||||
|
error_log(
|
||||||
|
"Voice Gemini Error: HTTP {$result['http_code']} | {$result['curl_error']} | {$result['body']}"
|
||||||
|
);
|
||||||
|
|
||||||
|
json_error(
|
||||||
|
'فشل تحليل الصوت بواسطة Gemini: ' . $geminiError['message'],
|
||||||
|
502,
|
||||||
|
[
|
||||||
|
'gemini_http_code' => $result['http_code'],
|
||||||
|
'gemini_status' => $geminiError['status'],
|
||||||
|
'gemini_model' => $model,
|
||||||
|
'audio_mime_type' => $mimeType,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$respData = json_decode($result['body'], true);
|
||||||
|
if (!is_array($respData)) {
|
||||||
|
json_error('تعذر قراءة رد Gemini', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
if (!is_string($rawText) || trim($rawText) === '') {
|
||||||
|
json_error('رد غير متوقع من Gemini', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = decodeModelJson($rawText);
|
||||||
|
if (!is_array($parsed)) {
|
||||||
|
error_log("Voice Gemini JSON parse failed. Raw: " . $rawText);
|
||||||
|
json_error('فشل تفسير الأمر الصوتي', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = isset($parsed['action']) && is_string($parsed['action'])
|
||||||
|
? strtolower(trim($parsed['action']))
|
||||||
|
: 'navigate';
|
||||||
|
$params = isset($parsed['params']) && is_array($parsed['params'])
|
||||||
|
? $parsed['params']
|
||||||
|
: [];
|
||||||
|
$confirmation = isset($parsed['confirmation']) && is_string($parsed['confirmation'])
|
||||||
|
? trim($parsed['confirmation'])
|
||||||
|
: 'تم فهم الأمر';
|
||||||
|
$transcript = isset($parsed['transcript']) && is_string($parsed['transcript'])
|
||||||
|
? trim($parsed['transcript'])
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => $action,
|
||||||
|
'params' => $params,
|
||||||
|
'confirmation' => $confirmation,
|
||||||
|
'transcript' => $transcript,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function callGeminiGenerateContent(string $model, array $payload, string $apiKey): array
|
||||||
|
{
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'x-goog-api-key: ' . $apiKey,
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
error_log("Gemini API Call Failed: HTTP $httpCode | Error: $error | URL: $url");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'body' => is_string($response) ? $response : '',
|
||||||
|
'http_code' => (int)$httpCode,
|
||||||
|
'curl_error' => $error ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeminiError(string $response): array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
$error = is_array($decoded) ? ($decoded['error'] ?? null) : null;
|
||||||
|
|
||||||
|
if (is_array($error)) {
|
||||||
|
return [
|
||||||
|
'message' => (string)($error['message'] ?? 'رد غير معروف من Gemini'),
|
||||||
|
'status' => (string)($error['status'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'message' => 'رد غير معروف من Gemini',
|
||||||
|
'status' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeModelJson(string $rawText): ?array
|
||||||
|
{
|
||||||
|
$text = trim($rawText);
|
||||||
|
|
||||||
|
// Remove fenced blocks if model wrapped JSON in ```json ... ```
|
||||||
|
if (str_starts_with($text, '```')) {
|
||||||
|
$text = preg_replace('/^```(?:json)?/i', '', $text) ?? $text;
|
||||||
|
$text = preg_replace('/```$/', '', $text) ?? $text;
|
||||||
|
$text = trim($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($text, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to extract the first JSON object
|
||||||
|
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $m) === 1) {
|
||||||
|
$decoded = json_decode($m[0], true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeVoiceAction(array $decoded, array $intent): array
|
||||||
|
{
|
||||||
|
$action = (string)($intent['action'] ?? '');
|
||||||
|
$params = is_array($intent['params'] ?? null) ? $intent['params'] : [];
|
||||||
|
|
||||||
|
$tenantId = (string)($decoded['tenant_id'] ?? '');
|
||||||
|
$userId = (string)($decoded['user_id'] ?? '');
|
||||||
|
$role = (string)($decoded['role'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
case 'list_invoices':
|
||||||
|
return executeListInvoices($tenantId, $userId, $role, $params);
|
||||||
|
|
||||||
|
case 'search_invoice':
|
||||||
|
return executeSearchInvoices($tenantId, $userId, $role, $params);
|
||||||
|
|
||||||
|
case 'check_quota':
|
||||||
|
if ($tenantId === '') {
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'action' => $action,
|
||||||
|
'message' => 'لا يمكن جلب تفاصيل الباقة بدون tenant_id',
|
||||||
|
'data' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => $action,
|
||||||
|
'message' => 'تم جلب استهلاك الباقة',
|
||||||
|
'data' => QuotaMiddleware::getUsageSummary($tenantId),
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'check_status':
|
||||||
|
return executeCheckStatus($tenantId, $role, $params);
|
||||||
|
|
||||||
|
case 'get_report':
|
||||||
|
return executeGetReport($tenantId, $role, $params);
|
||||||
|
|
||||||
|
case 'open_scanner':
|
||||||
|
case 'navigate':
|
||||||
|
case 'export_pdf':
|
||||||
|
return [
|
||||||
|
'status' => 'client_action',
|
||||||
|
'action' => $action,
|
||||||
|
'message' => 'يتطلب تنفيذ هذا الإجراء من واجهة التطبيق',
|
||||||
|
'data' => $params,
|
||||||
|
];
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'status' => 'not_supported',
|
||||||
|
'action' => $action,
|
||||||
|
'message' => 'الأمر مفهوم لكن غير مدعوم حالياً في التنفيذ المباشر',
|
||||||
|
'data' => $params,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("Voice Action Execution Error ({$action}): " . $e->getMessage());
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'action' => $action,
|
||||||
|
'message' => 'حدث خطأ أثناء تنفيذ الأمر داخلياً',
|
||||||
|
'data' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeListInvoices(string $tenantId, string $userId, string $role, array $params): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$bind = [];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'i.tenant_id = ?';
|
||||||
|
$bind[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role scoping for accountant/viewer (assigned companies only)
|
||||||
|
if (in_array($role, ['accountant', 'viewer'], true)) {
|
||||||
|
$stmtAssigned = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
||||||
|
$stmtAssigned->execute([$userId]);
|
||||||
|
$assigned = $stmtAssigned->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if (empty($assigned)) {
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'list_invoices',
|
||||||
|
'message' => 'لا توجد شركات مخصصة لك حالياً',
|
||||||
|
'data' => ['items' => [], 'count' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($assigned), '?'));
|
||||||
|
$where[] = "i.company_id IN ({$placeholders})";
|
||||||
|
foreach ($assigned as $companyId) {
|
||||||
|
$bind[] = $companyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['status']) && is_string($params['status'])) {
|
||||||
|
$where[] = 'i.status = ?';
|
||||||
|
$bind[] = trim($params['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['from']) && is_string($params['from'])) {
|
||||||
|
$where[] = 'i.invoice_date >= ?';
|
||||||
|
$bind[] = trim($params['from']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['to']) && is_string($params['to'])) {
|
||||||
|
$where[] = 'i.invoice_date <= ?';
|
||||||
|
$bind[] = trim($params['to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['company']) && is_string($params['company'])) {
|
||||||
|
$where[] = '(c.name LIKE ? OR c.name_en LIKE ?)';
|
||||||
|
$needle = '%' . trim($params['company']) . '%';
|
||||||
|
$bind[] = $needle;
|
||||||
|
$bind[] = $needle;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($params['limit']) ? (int)$params['limit'] : 20;
|
||||||
|
if ($limit < 1) $limit = 20;
|
||||||
|
if ($limit > 50) $limit = 50;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT i.id, i.invoice_number, i.invoice_date, i.status, i.grand_total, i.tax_amount, c.name AS company_name
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN companies c ON c.id = i.company_id
|
||||||
|
";
|
||||||
|
|
||||||
|
if (!empty($where)) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY i.created_at DESC LIMIT ' . $limit;
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($bind);
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($items as &$row) {
|
||||||
|
$row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'list_invoices',
|
||||||
|
'message' => 'تم جلب قائمة الفواتير',
|
||||||
|
'data' => [
|
||||||
|
'items' => $items,
|
||||||
|
'count' => count($items),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeSearchInvoices(string $tenantId, string $userId, string $role, array $params): array
|
||||||
|
{
|
||||||
|
// Reuse list logic with extra flexible filters.
|
||||||
|
$filters = [
|
||||||
|
'status' => $params['status'] ?? null,
|
||||||
|
'company' => $params['company'] ?? null,
|
||||||
|
'from' => $params['from'] ?? null,
|
||||||
|
'to' => $params['to'] ?? null,
|
||||||
|
'limit' => $params['limit'] ?? 20,
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = executeListInvoices($tenantId, $userId, $role, $filters);
|
||||||
|
|
||||||
|
if (($result['status'] ?? '') !== 'executed') {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $result['data']['items'] ?? [];
|
||||||
|
|
||||||
|
if (!empty($params['number']) && is_string($params['number'])) {
|
||||||
|
$needle = strtolower(trim($params['number']));
|
||||||
|
$items = array_values(array_filter($items, static function (array $row) use ($needle): bool {
|
||||||
|
return str_contains(strtolower((string)($row['invoice_number'] ?? '')), $needle);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($params['amount']) && is_numeric($params['amount'])) {
|
||||||
|
$target = (float)$params['amount'];
|
||||||
|
$items = array_values(array_filter($items, static function (array $row) use ($target): bool {
|
||||||
|
$value = (float)($row['grand_total'] ?? 0);
|
||||||
|
return abs($value - $target) <= 0.01;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'search_invoice',
|
||||||
|
'message' => 'تم تنفيذ البحث عن الفاتورة',
|
||||||
|
'data' => [
|
||||||
|
'items' => $items,
|
||||||
|
'count' => count($items),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeCheckStatus(string $tenantId, string $role, array $params): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$invoiceId = isset($params['invoice_id']) ? trim((string)$params['invoice_id']) : '';
|
||||||
|
$invoiceNumber = isset($params['invoice_number']) ? trim((string)$params['invoice_number']) : '';
|
||||||
|
|
||||||
|
if ($invoiceId === '' && $invoiceNumber === '') {
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'action' => 'check_status',
|
||||||
|
'message' => 'يرجى تحديد رقم الفاتورة أو معرفها',
|
||||||
|
'data' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$bind = [];
|
||||||
|
if ($invoiceId !== '') {
|
||||||
|
$where[] = 'i.id = ?';
|
||||||
|
$bind[] = $invoiceId;
|
||||||
|
}
|
||||||
|
if ($invoiceNumber !== '') {
|
||||||
|
$where[] = 'i.invoice_number = ?';
|
||||||
|
$bind[] = $invoiceNumber;
|
||||||
|
}
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where[] = 'i.tenant_id = ?';
|
||||||
|
$bind[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT i.id, i.invoice_number, i.status, i.invoice_date, i.grand_total, i.jofotara_uuid, c.name AS company_name
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN companies c ON c.id = i.company_id
|
||||||
|
WHERE " . implode(' AND ', $where) . "
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($bind);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'check_status',
|
||||||
|
'message' => 'لم يتم العثور على الفاتورة المطلوبة',
|
||||||
|
'data' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'check_status',
|
||||||
|
'message' => 'تم جلب حالة الفاتورة',
|
||||||
|
'data' => $row,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeGetReport(string $tenantId, string $role, array $params): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$type = strtolower(trim((string)($params['type'] ?? 'monthly')));
|
||||||
|
$period = trim((string)($params['period'] ?? date('Y-m')));
|
||||||
|
|
||||||
|
$periodRegex = '/^\d{4}-\d{2}$/';
|
||||||
|
if (!preg_match($periodRegex, $period)) {
|
||||||
|
$period = date('Y-m');
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = "DATE_FORMAT(i.invoice_date, '%Y-%m') = ?";
|
||||||
|
$bind = [$period];
|
||||||
|
|
||||||
|
if ($role !== 'super_admin') {
|
||||||
|
$where .= " AND i.tenant_id = ?";
|
||||||
|
$bind[] = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'tax') {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
COUNT(i.id) AS invoices_count,
|
||||||
|
ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax,
|
||||||
|
ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_with_tax
|
||||||
|
FROM invoices i
|
||||||
|
WHERE {$where}
|
||||||
|
";
|
||||||
|
} else {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
COUNT(i.id) AS invoices_count,
|
||||||
|
ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_amount,
|
||||||
|
ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax,
|
||||||
|
SUM(CASE WHEN i.status = 'approved' THEN 1 ELSE 0 END) AS approved_count
|
||||||
|
FROM invoices i
|
||||||
|
WHERE {$where}
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($bind);
|
||||||
|
$summary = $stmt->fetch() ?: [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'executed',
|
||||||
|
'action' => 'get_report',
|
||||||
|
'message' => 'تم إنشاء التقرير المختصر',
|
||||||
|
'data' => [
|
||||||
|
'type' => $type,
|
||||||
|
'period' => $period,
|
||||||
|
'summary' => $summary,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptIfNeeded(string $value): string
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$dec = Encryption::decrypt($value);
|
||||||
|
if ($dec !== false && $dec !== null) {
|
||||||
|
return (string)$dec;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Keep original value
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|||||||
35
app/modules_app/whatsapp/link_code.php
Normal file
35
app/modules_app/whatsapp/link_code.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Generate WhatsApp Link Code
|
||||||
|
* GET /v1/whatsapp/link-code
|
||||||
|
*
|
||||||
|
* Generates a one-time code that the user sends to the WhatsApp bot
|
||||||
|
* to link their phone number with their Musadaq account.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate a short, easy-to-type code
|
||||||
|
$code = strtoupper(substr(md5($userId . time() . random_int(1000, 9999)), 0, 6));
|
||||||
|
|
||||||
|
// Save the code (expires in 10 minutes)
|
||||||
|
$stmt = $db->prepare("UPDATE users SET whatsapp_link_code = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$code, $userId]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'code' => $code,
|
||||||
|
'expires_in' => 600, // 10 minutes
|
||||||
|
'instruction' => "أرسل هذه الرسالة للرقم التالي على واتساب:\n\nربط {$code}",
|
||||||
|
'bot_number' => env('WHATSAPP_BOT_NUMBER', '+962XXXXXXXXX'),
|
||||||
|
], 'تم إنشاء كود الربط');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'whatsapp/link-code', 'حدث خطأ في إنشاء كود الربط.');
|
||||||
|
}
|
||||||
259
app/modules_app/whatsapp/webhook.php
Normal file
259
app/modules_app/whatsapp/webhook.php
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WhatsApp Bot Webhook
|
||||||
|
* POST /v1/whatsapp/webhook
|
||||||
|
*
|
||||||
|
* Receives incoming WhatsApp messages (text + images) via the proxy bot.
|
||||||
|
* Flow: User sends invoice image → Bot processes via AI → Returns extracted data.
|
||||||
|
*
|
||||||
|
* Supported commands:
|
||||||
|
* - Image/Document: Extracts invoice data via AI
|
||||||
|
* - "ربط [CODE]": Links WhatsApp number to Musadaq account
|
||||||
|
* - "حالتي" or "status": Returns account summary
|
||||||
|
* - "مساعدة" or "help": Returns command list
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
|
||||||
|
// No auth middleware — this is a webhook from the bot proxy
|
||||||
|
// Verify webhook secret instead
|
||||||
|
$webhookSecret = env('WHATSAPP_WEBHOOK_SECRET', '');
|
||||||
|
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
|
||||||
|
|
||||||
|
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||||
|
json_error('Unauthorized webhook', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$body) {
|
||||||
|
json_error('Invalid payload', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $body['from'] ?? ''; // Phone number (962XXXXXXXXX)
|
||||||
|
$text = $body['message']['text'] ?? '';
|
||||||
|
$imageUrl = $body['message']['image_url'] ?? null;
|
||||||
|
$imageData = $body['message']['image_base64'] ?? null;
|
||||||
|
$mimeType = $body['message']['mime_type'] ?? 'image/jpeg';
|
||||||
|
|
||||||
|
if (empty($from)) {
|
||||||
|
json_error('Missing sender number', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$wa = new \App\Services\WhatsAppProxyService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Look up linked account by phone hash
|
||||||
|
$phoneClean = preg_replace('/[^0-9+]/', '', $from);
|
||||||
|
$phoneHash = hash('sha256', $phoneClean);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT u.id, u.tenant_id, u.name, u.role FROM users u WHERE u.phone_hash = ? AND u.is_active = 1 LIMIT 1");
|
||||||
|
$stmt->execute([$phoneHash]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
// 2. Handle commands
|
||||||
|
$textLower = mb_strtolower(trim($text));
|
||||||
|
|
||||||
|
// === LINK COMMAND ===
|
||||||
|
if (str_starts_with($textLower, 'ربط ') || str_starts_with($textLower, 'link ')) {
|
||||||
|
$code = trim(str_replace(['ربط', 'link'], '', $text));
|
||||||
|
handleLinkCommand($db, $wa, $from, $phoneHash, $code);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HELP COMMAND ===
|
||||||
|
if (in_array($textLower, ['مساعدة', 'help', '؟', '?'])) {
|
||||||
|
$wa->sendMessage($from, "🤖 *أوامر مُصادَق:*\n\n"
|
||||||
|
. "📸 أرسل صورة فاتورة → نستخرج البيانات بالـ AI\n"
|
||||||
|
. "🔗 ربط [الكود] → لربط رقمك بحسابك\n"
|
||||||
|
. "📊 حالتي → ملخص حسابك\n"
|
||||||
|
. "❓ مساعدة → هذه الرسالة\n\n"
|
||||||
|
. "للتسجيل: musadaq.intaleqapp.com");
|
||||||
|
json_success(null, 'Help sent');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ACCOUNT NOT LINKED ===
|
||||||
|
if (!$user) {
|
||||||
|
$wa->sendMessage($from, "👋 مرحباً!\n\n"
|
||||||
|
. "رقمك غير مربوط بحساب مُصادَق.\n"
|
||||||
|
. "لربط حسابك، أرسل: *ربط [الكود]*\n\n"
|
||||||
|
. "للحصول على الكود، افتح تطبيق مُصادَق → الإعدادات → ربط واتساب.\n\n"
|
||||||
|
. "أو سجّل حساب جديد: musadaq.intaleqapp.com");
|
||||||
|
json_success(null, 'Unlinked user guided');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userName = Encryption::decrypt($user['name']) ?: 'المستخدم';
|
||||||
|
|
||||||
|
// === STATUS COMMAND ===
|
||||||
|
if (in_array($textLower, ['حالتي', 'status', 'حالة'])) {
|
||||||
|
handleStatusCommand($db, $wa, $from, $user, $userName);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === IMAGE/INVOICE PROCESSING ===
|
||||||
|
if ($imageData || $imageUrl) {
|
||||||
|
handleInvoiceImage($db, $wa, $from, $user, $userName, $imageData, $imageUrl, $mimeType);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEFAULT: Unknown text ===
|
||||||
|
$wa->sendMessage($from, "مرحباً {$userName} 👋\n\n"
|
||||||
|
. "لم أفهم طلبك. يمكنك:\n"
|
||||||
|
. "📸 إرسال صورة فاتورة لاستخراج البيانات\n"
|
||||||
|
. "📊 كتابة *حالتي* لملخص حسابك\n"
|
||||||
|
. "❓ كتابة *مساعدة* لقائمة الأوامر");
|
||||||
|
|
||||||
|
json_success(null, 'Default response sent');
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[whatsapp/webhook] Error: " . $e->getMessage());
|
||||||
|
try {
|
||||||
|
$wa->sendMessage($from, "⚠️ حدث خطأ أثناء المعالجة. يرجى المحاولة مرة أخرى.");
|
||||||
|
} catch (\Throwable $ignore) {}
|
||||||
|
json_success(null, 'Error handled'); // Return 200 so the bot doesn't retry
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// HANDLER FUNCTIONS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
function handleLinkCommand($db, $wa, string $from, string $phoneHash, string $code): void
|
||||||
|
{
|
||||||
|
if (empty($code)) {
|
||||||
|
$wa->sendMessage($from, "❌ يرجى إرسال الكود. مثال: *ربط ABC123*");
|
||||||
|
json_success(null, 'Empty code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user by link code
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM users WHERE whatsapp_link_code = ? AND is_active = 1 LIMIT 1");
|
||||||
|
$stmt->execute([strtoupper(trim($code))]);
|
||||||
|
$targetUser = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$targetUser) {
|
||||||
|
$wa->sendMessage($from, "❌ الكود غير صحيح. تأكد من الكود في تطبيق مُصادَق → الإعدادات → ربط واتساب.");
|
||||||
|
json_success(null, 'Invalid code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's phone hash
|
||||||
|
$updateStmt = $db->prepare("UPDATE users SET phone_hash = ?, whatsapp_linked = 1, whatsapp_link_code = NULL WHERE id = ?");
|
||||||
|
$updateStmt->execute([$phoneHash, $targetUser['id']]);
|
||||||
|
|
||||||
|
$wa->sendMessage($from, "✅ تم ربط رقمك بحسابك بنجاح! 🎉\n\n"
|
||||||
|
. "الآن يمكنك إرسال صور الفواتير مباشرة هنا وسنستخرج البيانات تلقائياً.");
|
||||||
|
|
||||||
|
json_success(null, 'Account linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusCommand($db, $wa, string $from, array $user, string $userName): void
|
||||||
|
{
|
||||||
|
$tenantId = $user['tenant_id'];
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
$invoiceStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) as pending FROM invoices WHERE tenant_id = ?");
|
||||||
|
$invoiceStmt->execute([$tenantId]);
|
||||||
|
$stats = $invoiceStmt->fetch();
|
||||||
|
|
||||||
|
$subStmt = $db->prepare("SELECT plan_slug, invoices_used_this_month, max_invoices_per_month FROM subscriptions WHERE tenant_id = ?");
|
||||||
|
$subStmt->execute([$tenantId]);
|
||||||
|
$sub = $subStmt->fetch();
|
||||||
|
|
||||||
|
$plan = $sub['plan_slug'] ?? 'free';
|
||||||
|
$used = $sub['invoices_used_this_month'] ?? 0;
|
||||||
|
$max = $sub['max_invoices_per_month'] ?? 15;
|
||||||
|
|
||||||
|
$msg = "📊 *ملخص حسابك، {$userName}:*\n\n"
|
||||||
|
. "📋 إجمالي الفواتير: {$stats['total']}\n"
|
||||||
|
. "⏳ بانتظار المراجعة: {$stats['pending']}\n"
|
||||||
|
. "📦 الباقة: {$plan}\n"
|
||||||
|
. "🔢 الاستخدام: {$used}/{$max} فاتورة هذا الشهر\n\n"
|
||||||
|
. "🌐 لوحة التحكم: musadaq.intaleqapp.com";
|
||||||
|
|
||||||
|
$wa->sendMessage($from, $msg);
|
||||||
|
json_success(null, 'Status sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInvoiceImage($db, $wa, string $from, array $user, string $userName, ?string $imageData, ?string $imageUrl, string $mimeType): void
|
||||||
|
{
|
||||||
|
$wa->sendMessage($from, "📸 استلمت الصورة! جارٍ استخراج البيانات بالذكاء الاصطناعي... ⏳");
|
||||||
|
|
||||||
|
// Get image data
|
||||||
|
if (!$imageData && $imageUrl) {
|
||||||
|
$imageContent = @file_get_contents($imageUrl);
|
||||||
|
if (!$imageContent) {
|
||||||
|
$wa->sendMessage($from, "❌ فشل تحميل الصورة. يرجى إرسالها مرة أخرى.");
|
||||||
|
json_success(null, 'Image download failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$imageData = base64_encode($imageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$imageData) {
|
||||||
|
$wa->sendMessage($from, "❌ لم أتمكن من قراءة الصورة.");
|
||||||
|
json_success(null, 'No image data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run AI extraction
|
||||||
|
$extracted = AI::extractInvoiceData($imageData, $mimeType);
|
||||||
|
|
||||||
|
if (!$extracted) {
|
||||||
|
$wa->sendMessage($from, "⚠️ لم أتمكن من استخراج البيانات. تأكد أن الصورة واضحة وتحتوي على فاتورة.");
|
||||||
|
json_success(null, 'AI extraction failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
$supplierName = $extracted['supplier']['name'] ?? 'غير محدد';
|
||||||
|
$invoiceNum = $extracted['invoice_number'] ?? '-';
|
||||||
|
$invoiceDate = $extracted['invoice_date'] ?? '-';
|
||||||
|
$subtotal = number_format((float)($extracted['subtotal'] ?? 0), 2);
|
||||||
|
$tax = number_format((float)($extracted['tax_amount'] ?? 0), 2);
|
||||||
|
$total = number_format((float)($extracted['grand_total'] ?? 0), 2);
|
||||||
|
$linesCount = count($extracted['lines'] ?? []);
|
||||||
|
|
||||||
|
$msg = "✅ *تم استخراج بيانات الفاتورة:*\n\n"
|
||||||
|
. "🏢 المورد: {$supplierName}\n"
|
||||||
|
. "🔢 رقم الفاتورة: {$invoiceNum}\n"
|
||||||
|
. "📅 التاريخ: {$invoiceDate}\n"
|
||||||
|
. "📦 البنود: {$linesCount}\n"
|
||||||
|
. "───────────────\n"
|
||||||
|
. "💰 المبلغ قبل الضريبة: {$subtotal} دينار\n"
|
||||||
|
. "🏛️ الضريبة: {$tax} دينار\n"
|
||||||
|
. "📊 *الإجمالي: {$total} دينار*\n\n";
|
||||||
|
|
||||||
|
// Add warnings if any
|
||||||
|
if (!empty($extracted['validation_warnings'])) {
|
||||||
|
$msg .= "⚠️ *تحذيرات:*\n";
|
||||||
|
foreach ($extracted['validation_warnings'] as $w) {
|
||||||
|
$msg .= "• {$w}\n";
|
||||||
|
}
|
||||||
|
$msg .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg .= "💡 لحفظ هذه الفاتورة رسمياً، ارفعها من تطبيق مُصادَق.";
|
||||||
|
|
||||||
|
$wa->sendMessage($from, $msg);
|
||||||
|
|
||||||
|
// Log the interaction
|
||||||
|
try {
|
||||||
|
AuditLogger::log('whatsapp.invoice_extracted', 'whatsapp', null, null, [
|
||||||
|
'from' => substr($from, 0, 6) . '****',
|
||||||
|
'invoice_number' => $invoiceNum,
|
||||||
|
'total' => $total,
|
||||||
|
], ['user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success(null, 'Invoice extracted via WhatsApp');
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
"guzzlehttp/guzzle": "^7.9",
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
"respect/validation": "^2.3",
|
"respect/validation": "^2.3",
|
||||||
"league/flysystem": "^3.28",
|
"league/flysystem": "^3.28",
|
||||||
"symfony/mailer": "^7.1"
|
"symfony/mailer": "^7.1",
|
||||||
|
"phpoffice/phpspreadsheet": "^2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0",
|
"phpunit/phpunit": "^11.0",
|
||||||
|
|||||||
31
database/migrations/004_gamification_whatsapp.sql
Normal file
31
database/migrations/004_gamification_whatsapp.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- Gamification Tables for Musadaq
|
||||||
|
|
||||||
|
-- Points tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS user_points (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(36) NOT NULL,
|
||||||
|
tenant_id VARCHAR(36) NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
points INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_points_user (user_id),
|
||||||
|
INDEX idx_user_points_tenant (tenant_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Badge tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS user_badges (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(36) NOT NULL,
|
||||||
|
tenant_id VARCHAR(36) NOT NULL,
|
||||||
|
badge_key VARCHAR(50) NOT NULL,
|
||||||
|
badge_name VARCHAR(100) NOT NULL,
|
||||||
|
badge_icon VARCHAR(10) NOT NULL DEFAULT '🏅',
|
||||||
|
earned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_badge (user_id, badge_key),
|
||||||
|
INDEX idx_user_badges_tenant (tenant_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- WhatsApp columns on users table
|
||||||
|
-- Run these one at a time. If column already exists, it will error — just skip it.
|
||||||
|
ALTER TABLE users ADD COLUMN whatsapp_link_code VARCHAR(10) DEFAULT NULL;
|
||||||
|
ALTER TABLE users ADD COLUMN whatsapp_linked TINYINT(1) DEFAULT 0;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user