Compare commits

...

142 Commits

Author SHA1 Message Date
Hamza-Ayed
5f62455113 Update: 2026-05-25 21:44:11 2026-05-25 21:44:11 +03:00
Hamza-Ayed
2f1a6f9c85 Update: 2026-05-17 18:47:51 2026-05-17 18:47:51 +03:00
Hamza-Ayed
9ad361e992 Update: 2026-05-16 01:40:56 2026-05-16 01:40:56 +03:00
Hamza-Ayed
aceb7d324f Update: 2026-05-16 01:36:22 2026-05-16 01:36:22 +03:00
Hamza-Ayed
24a9f064a1 Update: 2026-05-16 00:33:22 2026-05-16 00:33:22 +03:00
Hamza-Ayed
663896becb Update: 2026-05-16 00:27:57 2026-05-16 00:27:59 +03:00
Hamza-Ayed
e93f1d4f34 feat: implement annual subscription model across backend quota system and flutter UI 2026-05-16 00:15:38 +03:00
Hamza-Ayed
bddee7ca2d Update: 2026-05-16 00:04:29 2026-05-16 00:04:30 +03:00
Hamza-Ayed
2d81aa2fb0 fix: add missing line_total field during invoice item insertion to prevent General error 1364 2026-05-16 00:01:12 +03:00
Hamza-Ayed
e798b970f1 Update: 2026-05-15 23:48:27 2026-05-15 23:48:27 +03:00
Hamza-Ayed
a98a5abcce fix: bypass invoice quota checks for super_admin in all upload and creation endpoints 2026-05-15 23:41:48 +03:00
Hamza-Ayed
3f0534ba0d fix: make summary sheet QR point to verify_qr to avoid login redirect confusion 2026-05-15 23:05:08 +03:00
Hamza-Ayed
e0dc1712ca fix: use cache-busting 'verify_qr' route to bypass Varnish cache caching old 302 redirects 2026-05-15 21:59:36 +03:00
Hamza-Ayed
53284b971a Update: 2026-05-15 21:55:17 2026-05-15 21:55:17 +03:00
Hamza-Ayed
fa73062023 fix: make Excel hyperlinks point to verification URL instead of root to avoid accidental login redirects 2026-05-15 21:54:17 +03:00
Hamza-Ayed
68f6e76da8 Update: 2026-05-15 18:00:25 2026-05-15 18:00:25 +03:00
Hamza-Ayed
8b69c99776 Update: 2026-05-15 17:55:39 2026-05-15 17:55:40 +03:00
Hamza-Ayed
cf8cf829d8 Update: 2026-05-15 17:50:52 2026-05-15 17:50:52 +03:00
Hamza-Ayed
813197c869 Update: 2026-05-15 17:43:57 2026-05-15 17:43:57 +03:00
Hamza-Ayed
ee2ea3a111 Update: 2026-05-15 15:44:31 2026-05-15 15:44:31 +03:00
Hamza-Ayed
9ecc03adb1 Update: 2026-05-15 15:30:00 2026-05-15 15:30:00 +03:00
Hamza-Ayed
3eecb2f602 fix: wrap logo/QR Drawing in try-catch, use $logoPath var, add cURL fallback for QR download 2026-05-15 15:18:25 +03:00
Hamza-Ayed
48fcdaf4b8 Update: 2026-05-15 15:08:36 2026-05-15 15:08:36 +03:00
Hamza-Ayed
51119e3201 Update: 2026-05-15 15:06:01 2026-05-15 15:06:02 +03:00
Hamza-Ayed
7ee897ff3d Update: 2026-05-15 15:02:14 2026-05-15 15:02:14 +03:00
Hamza-Ayed
54a4acdcab Update: 2026-05-15 14:28:07 2026-05-15 14:28:07 +03:00
Hamza-Ayed
f5260d854e Update: 2026-05-15 14:26:05 2026-05-15 14:26:05 +03:00
Hamza-Ayed
9e078bdfa7 Update: 2026-05-15 14:23:28 2026-05-15 14:23:28 +03:00
Hamza-Ayed
7e9a088ea1 Update: 2026-05-15 04:45:18 2026-05-15 04:45:18 +03:00
Hamza-Ayed
698d0df01e Update: 2026-05-15 04:41:45 2026-05-15 04:41:45 +03:00
Hamza-Ayed
2f1ecca593 Update: 2026-05-15 04:35:25 2026-05-15 04:35:25 +03:00
Hamza-Ayed
1ca7e01ce0 Update: 2026-05-13 22:58:30 2026-05-13 22:58:30 +03:00
Hamza-Ayed
30da101415 Update: 2026-05-12 01:40:41 2026-05-12 01:40:41 +03:00
Hamza-Ayed
e8a9b59a46 Update: 2026-05-12 01:35:12 2026-05-12 01:35:12 +03:00
Hamza-Ayed
ae5eba09aa Update: 2026-05-12 01:29:57 2026-05-12 01:29:57 +03:00
Hamza-Ayed
d6cedd23c1 Update: 2026-05-12 01:19:04 2026-05-12 01:19:04 +03:00
Hamza-Ayed
ba621c9896 Update: 2026-05-12 01:07:38 2026-05-12 01:07:38 +03:00
Hamza-Ayed
8948397af9 Update: 2026-05-11 22:31:36 2026-05-11 22:31:36 +03:00
Hamza-Ayed
79f98a4afb Update: 2026-05-11 01:16:32 2026-05-11 01:16:32 +03:00
Hamza-Ayed
d86a00fe03 Update: 2026-05-11 01:09:54 2026-05-11 01:09:55 +03:00
Hamza-Ayed
d6a06cadf9 Update: 2026-05-09 21:35:36 2026-05-09 21:35:36 +03:00
Hamza-Ayed
72a00bb308 Update: 2026-05-09 21:27:35 2026-05-09 21:27:35 +03:00
Hamza-Ayed
3a9a3be04f Update: 2026-05-09 20:53:51 2026-05-09 20:53:51 +03:00
Hamza-Ayed
7541a042a7 Update: 2026-05-09 20:51:16 2026-05-09 20:51:16 +03:00
Hamza-Ayed
0dbf812be4 Update: 2026-05-09 18:11:10 2026-05-09 18:11:10 +03:00
Hamza-Ayed
e1bdda3cbf Update: 2026-05-09 18:00:43 2026-05-09 18:00:43 +03:00
Hamza-Ayed
8780054553 Update: 2026-05-09 17:43:20 2026-05-09 17:43:20 +03:00
Hamza-Ayed
d7c7920b4a Update: 2026-05-09 17:36:15 2026-05-09 17:36:15 +03:00
Hamza-Ayed
b9ba9c5030 Update: 2026-05-09 17:25:49 2026-05-09 17:25:49 +03:00
Hamza-Ayed
c94855ed9c Update: 2026-05-09 17:21:01 2026-05-09 17:21:01 +03:00
Hamza-Ayed
f75e2719fa Update: 2026-05-09 17:15:32 2026-05-09 17:15:32 +03:00
Hamza-Ayed
32b9d829eb Update: 2026-05-09 17:09:49 2026-05-09 17:09:49 +03:00
Hamza-Ayed
47df9253f9 Update: 2026-05-09 13:16:23 2026-05-09 13:16:23 +03:00
Hamza-Ayed
c0896468a7 Update: 2026-05-09 13:10:07 2026-05-09 13:10:07 +03:00
Hamza-Ayed
9159e2d274 Update: 2026-05-09 01:39:40 2026-05-09 01:39:40 +03:00
Hamza-Ayed
c23c58c188 Update: 2026-05-09 01:29:39 2026-05-09 01:29:39 +03:00
Hamza-Ayed
812aa7eb5d Update: 2026-05-08 23:25:23 2026-05-08 23:25:23 +03:00
Hamza-Ayed
67cc322f5e Update: 2026-05-08 23:18:10 2026-05-08 23:18:10 +03:00
Hamza-Ayed
72424bf92c Update: 2026-05-08 15:19:08 2026-05-08 15:19:08 +03:00
Hamza-Ayed
07fd3f9ba7 Update: 2026-05-08 15:18:01 2026-05-08 15:18:01 +03:00
Hamza-Ayed
7ea42f0f3b Update: 2026-05-08 15:02:13 2026-05-08 15:02:13 +03:00
Hamza-Ayed
80949e584c Update: 2026-05-08 14:52:14 2026-05-08 14:52:14 +03:00
Hamza-Ayed
0d8ff9a7b1 Update: 2026-05-08 14:47:40 2026-05-08 14:47:40 +03:00
Hamza-Ayed
30974da55b Update: 2026-05-08 14:44:54 2026-05-08 14:44:54 +03:00
Hamza-Ayed
9295be081c Update: 2026-05-08 14:35:20 2026-05-08 14:35:20 +03:00
Hamza-Ayed
b913ff25c8 Update: 2026-05-08 14:34:10 2026-05-08 14:34:10 +03:00
Hamza-Ayed
9bfd394b26 Update: 2026-05-08 14:29:22 2026-05-08 14:29:22 +03:00
Hamza-Ayed
be0571648a Update: 2026-05-08 14:11:53 2026-05-08 14:11:53 +03:00
Hamza-Ayed
155c2d0fc0 Update: 2026-05-08 14:05:50 2026-05-08 14:05:50 +03:00
Hamza-Ayed
cfc330e291 Update: 2026-05-08 13:52:23 2026-05-08 13:52:23 +03:00
Hamza-Ayed
9832493d59 Update: 2026-05-08 13:48:41 2026-05-08 13:48:41 +03:00
Hamza-Ayed
18d678bc39 Update: 2026-05-08 06:32:07 2026-05-08 06:32:07 +03:00
Hamza-Ayed
85ea0e4340 Update: 2026-05-08 06:28:54 2026-05-08 06:28:54 +03:00
Hamza-Ayed
3db1a12e4b Update: 2026-05-08 06:25:53 2026-05-08 06:25:53 +03:00
Hamza-Ayed
753497649a Update: 2026-05-08 06:19:56 2026-05-08 06:19:56 +03:00
Hamza-Ayed
df92a44878 Update: 2026-05-08 05:24:38 2026-05-08 05:24:38 +03:00
Hamza-Ayed
d2d345b6a0 Update: 2026-05-08 05:09:43 2026-05-08 05:09:43 +03:00
Hamza-Ayed
6db8986fca Update: 2026-05-08 04:58:23 2026-05-08 04:58:23 +03:00
Hamza-Ayed
4721ca83da Update: 2026-05-08 02:25:00 2026-05-08 02:25:00 +03:00
Hamza-Ayed
189382e065 Update: 2026-05-08 02:22:45 2026-05-08 02:22:45 +03:00
Hamza-Ayed
b49af44139 Update: 2026-05-08 02:11:29 2026-05-08 02:11:29 +03:00
Hamza-Ayed
1cd511f12e Update: 2026-05-08 01:59:25 2026-05-08 01:59:25 +03:00
Hamza-Ayed
7528ec992d Update: 2026-05-08 01:52:24 2026-05-08 01:52:24 +03:00
Hamza-Ayed
f38a64c6f7 Update: 2026-05-08 01:48:19 2026-05-08 01:48:19 +03:00
Hamza-Ayed
7680847e8c Update: 2026-05-08 01:45:04 2026-05-08 01:45:04 +03:00
Hamza-Ayed
ed8203a02e Update: 2026-05-08 01:41:28 2026-05-08 01:41:28 +03:00
Hamza-Ayed
6b4e7721ee Update: 2026-05-08 01:33:35 2026-05-08 01:33:35 +03:00
Hamza-Ayed
23813fee95 Update: 2026-05-08 01:27:14 2026-05-08 01:27:14 +03:00
Hamza-Ayed
928e8e27e3 Update: 2026-05-08 01:15:44 2026-05-08 01:15:44 +03:00
Hamza-Ayed
1a6ed52a52 Update: 2026-05-08 01:04:27 2026-05-08 01:04:27 +03:00
Hamza-Ayed
4994994ad0 Update: 2026-05-08 00:52:01 2026-05-08 00:52:01 +03:00
Hamza-Ayed
522885d257 Update: 2026-05-08 00:43:22 2026-05-08 00:43:22 +03:00
Hamza-Ayed
08e2a87c10 Update: 2026-05-08 00:26:39 2026-05-08 00:26:40 +03:00
Hamza-Ayed
51d1d42f75 Update: 2026-05-07 23:24:12 2026-05-07 23:24:12 +03:00
Hamza-Ayed
80f3d257b0 Update: 2026-05-07 23:06:22 2026-05-07 23:06:22 +03:00
Hamza-Ayed
e04229dfbe Update: 2026-05-07 22:19:17 2026-05-07 22:19:18 +03:00
Hamza-Ayed
d8820efa24 Update: 2026-05-07 18:56:48 2026-05-07 18:56:48 +03:00
Hamza-Ayed
528b3ca247 Update: 2026-05-07 18:41:16 2026-05-07 18:41:16 +03:00
Hamza-Ayed
3cdab9dccc Update: 2026-05-07 18:32:31 2026-05-07 18:32:31 +03:00
Hamza-Ayed
10432e7b81 Update: 2026-05-07 18:29:37 2026-05-07 18:29:38 +03:00
Hamza-Ayed
230609e0a0 Update: 2026-05-07 18:27:10 2026-05-07 18:27:10 +03:00
Hamza-Ayed
8ee3557109 Update: 2026-05-07 18:16:10 2026-05-07 18:16:10 +03:00
Hamza-Ayed
01956fa714 Update: 2026-05-07 16:11:36 2026-05-07 16:11:36 +03:00
Hamza-Ayed
3b5f490efc Update: 2026-05-07 15:49:13 2026-05-07 15:49:13 +03:00
Hamza-Ayed
24ae4e2183 Update: 2026-05-07 13:52:09 2026-05-07 13:52:09 +03:00
Hamza-Ayed
6f3e2b9f50 Update: 2026-05-07 13:50:26 2026-05-07 13:50:26 +03:00
Hamza-Ayed
f7aee80553 Update: 2026-05-07 13:47:48 2026-05-07 13:47:48 +03:00
Hamza-Ayed
b8d9b3343e Update: 2026-05-07 11:52:29 2026-05-07 11:52:30 +03:00
Hamza-Ayed
bd7164ed23 Update: 2026-05-07 03:50:16 2026-05-07 03:50:16 +03:00
Hamza-Ayed
209f721cd6 Update: 2026-05-07 03:35:03 2026-05-07 03:35:04 +03:00
Hamza-Ayed
a5623d5b84 Update: 2026-05-07 03:26:24 2026-05-07 03:26:24 +03:00
Hamza-Ayed
440c8c1633 Update: 2026-05-07 03:23:32 2026-05-07 03:23:32 +03:00
Hamza-Ayed
a7915bab46 Update: 2026-05-07 03:20:30 2026-05-07 03:20:30 +03:00
Hamza-Ayed
4dc3d3783f Update: 2026-05-07 03:19:21 2026-05-07 03:19:21 +03:00
Hamza-Ayed
55806721e7 Update: 2026-05-07 03:16:40 2026-05-07 03:16:40 +03:00
Hamza-Ayed
6cefee3d42 Update: 2026-05-07 03:12:04 2026-05-07 03:12:04 +03:00
Hamza-Ayed
bfb6368ec8 Update: 2026-05-07 03:06:15 2026-05-07 03:06:15 +03:00
Hamza-Ayed
272971fc5b Update: 2026-05-07 02:08:54 2026-05-07 02:08:54 +03:00
Hamza-Ayed
57ac6047b8 Update: 2026-05-07 02:01:59 2026-05-07 02:01:59 +03:00
Hamza-Ayed
e5b70a01ef Update: 2026-05-07 01:47:01 2026-05-07 01:47:01 +03:00
Hamza-Ayed
f206591c01 Update: 2026-05-07 01:18:53 2026-05-07 01:18:53 +03:00
Hamza-Ayed
8a935dc362 Update: 2026-05-07 01:14:37 2026-05-07 01:14:37 +03:00
Hamza-Ayed
e36a078de2 Update: 2026-05-07 00:40:35 2026-05-07 00:40:35 +03:00
Hamza-Ayed
2449e44cb0 Update: 2026-05-07 00:14:28 2026-05-07 00:14:29 +03:00
Hamza-Ayed
dd364fc918 Update: 2026-05-06 21:24:56 2026-05-06 21:24:56 +03:00
Hamza-Ayed
3d4e636fbe Update: 2026-05-06 17:13:24 2026-05-06 17:13:24 +03:00
Hamza-Ayed
019bff7e37 Update: 2026-05-06 17:10:14 2026-05-06 17:10:14 +03:00
Hamza-Ayed
a9a2c65bee Update: 2026-05-06 05:11:51 2026-05-06 05:11:51 +03:00
Hamza-Ayed
01234bf3f2 Update: 2026-05-06 04:55:22 2026-05-06 04:55:22 +03:00
Hamza-Ayed
0dcced4142 Update: 2026-05-06 04:02:34 2026-05-06 04:02:34 +03:00
Hamza-Ayed
164651eb6d Update: 2026-05-06 03:34:30 2026-05-06 03:34:30 +03:00
Hamza-Ayed
abccf033a6 Update: 2026-05-06 03:30:43 2026-05-06 03:30:43 +03:00
Hamza-Ayed
79274ce72f Update: 2026-05-06 03:29:45 2026-05-06 03:29:45 +03:00
Hamza-Ayed
c8fef468aa Update: 2026-05-06 03:26:05 2026-05-06 03:26:05 +03:00
Hamza-Ayed
b874b66e6b Update: 2026-05-06 03:16:32 2026-05-06 03:16:32 +03:00
Hamza-Ayed
3a29f26d56 Update: 2026-05-06 03:13:03 2026-05-06 03:13:03 +03:00
Hamza-Ayed
13c2f75432 Update: 2026-05-06 03:09:37 2026-05-06 03:09:37 +03:00
Hamza-Ayed
d9d2edac47 Update: 2026-05-06 03:04:47 2026-05-06 03:04:47 +03:00
Hamza-Ayed
9952e0eca5 Update: 2026-05-06 02:59:42 2026-05-06 02:59:43 +03:00
Hamza-Ayed
dc2ba2ebcb Update: 2026-05-06 01:44:49 2026-05-06 01:44:49 +03:00
Hamza-Ayed
2176893eee Update: 2026-05-06 01:43:59 2026-05-06 01:43:59 +03:00
Hamza-Ayed
c7a152af81 Update: 2026-05-06 01:41:33 2026-05-06 01:41:33 +03:00
327 changed files with 31901 additions and 1214 deletions

View File

@@ -10,7 +10,9 @@ use App\Services\InvoiceExtractionService;
*/
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)
@@ -23,14 +25,13 @@ class AI
return null;
}
$service = new InvoiceExtractionService();
$prompt = $service->buildExtractionPrompt();
$prompt = AIConfig::getExtractionPrompt();
$payload = [
"contents" => [
[
"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" => [
"mime_type" => $mimeType,
@@ -45,18 +46,49 @@ class AI
]
];
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Retry with exponential backoff for 503/429 errors
for ($attempt = 1; $attempt <= self::$maxRetries; $attempt++) {
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
if ($attempt < self::$maxRetries) {
$wait = pow(2, $attempt) + rand(1, 3);
echo " Retrying in {$wait}s (cURL error)...\n";
sleep($wait);
continue;
}
return null;
}
if ($httpCode === 200) {
break; // Success
}
// Retry on 503 (overloaded) or 429 (rate limit)
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
$wait = pow(2, $attempt) + rand(1, 3);
echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n";
sleep($wait);
continue;
}
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
return null;
}
if ($httpCode !== 200) {
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
error_log("AI Error: All retries exhausted. Last code: $httpCode");
return null;
}
@@ -64,7 +96,86 @@ class AI
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResponse) return null;
// --- ADDED FOR DEBUGGING ---
@file_put_contents(STORAGE_PATH . '/logs/worker.log', "[" . date('Y-m-d H:i:s') . "] [AI_RAW_RESPONSE]\n" . $textResponse . "\n", FILE_APPEND);
// ---------------------------
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
View 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
View 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
View 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
View 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]);
}
}

View 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;
}
}

View File

@@ -13,12 +13,36 @@ final class Validator
{
$errors = [];
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.";
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.";
}
// 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;
}

View 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
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\Middleware;
use App\Core\Database;
use App\Core\Cache;
final class QuotaMiddleware
{
@@ -22,17 +23,26 @@ final class QuotaMiddleware
*/
public static function checkInvoiceQuota(string $tenantId): array
{
$db = Database::getInstance();
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub === false || $sub === null) {
$db = Database::getInstance();
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub) {
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
}
}
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
@@ -47,10 +57,12 @@ final class QuotaMiddleware
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()) {
$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("
UPDATE subscriptions
@@ -66,15 +78,15 @@ final class QuotaMiddleware
$sub['current_period_start'] = $newStart;
$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
$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) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
@@ -100,6 +112,9 @@ final class QuotaMiddleware
WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
// Invalidate cache
Cache::delete("quota_sub_{$tenantId}");
}
/**
@@ -230,6 +245,11 @@ final class QuotaMiddleware
$companiesLimit = (int)$sub['max_companies'];
$usersLimit = (int)($sub['max_users'] ?? 999);
// Check for pending payment request
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$pendingPayment = $stmt->fetch();
return [
'has_subscription' => true,
'plan_id' => $sub['plan_id'] ?? 'free',
@@ -239,6 +259,11 @@ final class QuotaMiddleware
'status' => $sub['status'],
'ai_features' => (bool)($sub['ai_features'] ?? false),
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
'pending_payment' => $pendingPayment ? [
'id' => $pendingPayment['id'],
'plan_id' => $pendingPayment['plan_id'],
'reference' => $pendingPayment['internal_reference']
] : null,
'invoices' => [
'used' => $invoicesUsed,

View File

@@ -15,54 +15,61 @@ final class RateLimitMiddleware
*/
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';
$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+');
if ($fp === false) {
// If we can't open the file, fail open (don't block all users)
return;
}
if ($fp === false) return;
try {
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
$now = time();
$content = stream_get_contents($fp);
flock($fp, LOCK_EX);
$now = time();
$content = stream_get_contents($fp);
$requests = [];
if (!empty($content)) {
$decoded = json_decode($content, true);
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) {
flock($fp, LOCK_UN);
fclose($fp);
header('Retry-After: ' . $timeWindow);
json_error('Too Many Requests. Please slow down.', 429);
}
// Record this request
$requests[] = $now;
// Write updated data back
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($requests));
} finally {
flock($fp, LOCK_UN);
fclose($fp);

View 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);
}
}

View 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),
];
}
}

View File

@@ -19,13 +19,16 @@ class InvoiceExtractionService
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
════════════════════════════════════════
## التحقق الرياضي (إلزامي):
## التحقق الرياضي والفواتير الشاملة للضريبة (إلزامي):
════════════════════════════════════════
- line_total = (quantity × unit_price) - discount لكل سطر
- معظم فواتير التجزئة والسوبرماركت (POS) في الأردن تكون "شاملة للضريبة" (Tax Inclusive).
- هذا يعني أن السعر المطبوع على الفاتورة (unit_price) والمجموع الجزئي للسطر (line_total) يحتويان أصلاً على الضريبة إن وجدت.
- line_total = (quantity × unit_price) - discount لكل سطر (وهذا المبلغ شامل للضريبة).
- subtotal = مجموع كل line_total
- tax_amount = مجموع (line_total × tax_rate) لكل سطر
- grand_total = subtotal - discount_total + tax_amount
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: سجِّله في validation_warnings، واستخدم القيم المحسوبة
- grand_total = subtotal - discount_total (يجب أن يتطابق تماماً مع المبلغ الكلي المطلوب من العميل في الفاتورة).
- tax_amount = مجموع الضرائب المحسوبة عكسياً من line_total (أو كما هي مذكورة صراحةً في أسفل الفاتورة). إياك أن تضيف tax_amount فوق subtotal إذا كانت الفاتورة شاملة للضريبة.
- إذا كانت الفاتورة من النوع النادر غير الشامل للضريبة (Tax Exclusive): grand_total = subtotal - discount_total + tax_amount
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: يجب أن تعطي الأولوية القصوى لتطابق `grand_total` مع الرقم المطبوع الذي تم دفعه فعلياً، وسجِّل أي ملاحظات في validation_warnings.
════════════════════════════════════════
## جدول الضرائب الأردنية (مرجعك الإلزامي):
@@ -90,7 +93,8 @@ class InvoiceExtractionService
- زيت الزيتون غير المعدل كيماوياً
- سكر مكرر (عدا سكر القصب)
- الشاي الأسود (عبوات ≤ 3 كغ)
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (≤ 3 كغ)
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (مثل حليب نيدو)
- الألبان (اللبن الرائب، الشنينة، لبن حمودة، الخ) والأجبان البيضاء العادية.
- بيض المائدة
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
- أجهزة الهواتف الذكية
@@ -106,10 +110,10 @@ class InvoiceExtractionService
════════════════════════════════════════
## قواعد تصنيف الضريبة لكل سطر:
════════════════════════════════════════
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ).
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات.
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 فقط بدون أي نص:
════════════════════════════════════════
{
"invoice_number": "string | null",
"invoice_date": "YYYY-MM-DD | null",
"invoice_type": "cash | credit",
"payment_method_code": "013 | 010 | 001",
"ubl_type_code": "388",
"supplier": {
"name": "string | null",
"tin": "string | null",
"address": "string | null"
},
"buyer": {
"name": "string | null",
"tin": "string | null",
"national_id": "string | null"
},
"lines": [
"invoices": [
{
"line_number": 1,
"description": "string",
"quantity": 1.000,
"unit_price": 0.000,
"discount": 0.000,
"tax_rate": 0.16,
"tax_category": "S | Z | E | O",
"tax_exempt_reason": "string | null",
"line_total": 0.000
"invoice_number": "string | null",
"invoice_date": "YYYY-MM-DD | null",
"invoice_type": "cash | credit",
"payment_method_code": "013 | 010 | 001",
"ubl_type_code": "388",
"supplier": {
"name": "string | null",
"tin": "string | null",
"address": "string | null"
},
"buyer": {
"name": "string | null",
"tin": "string | null",
"national_id": "string | null"
},
"lines": [
{
"line_number": 1,
"description": "string",
"quantity": 1.000,
"unit_price": 0.000,
"discount": 0.000,
"tax_rate": 0.16,
"tax_category": "standard | zero_rated | exempt | special",
"tax_exempt_reason": "string | null",
"line_total": 0.000
}
],
"subtotal": 0.000,
"discount_total": 0.000,
"tax_amount": 0.000,
"grand_total": 0.000,
"currency_code": "JOD",
"math_verified": true,
"validation_warnings": [],
"ai_confidence": 0.95
}
],
"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;
}

View 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());
}
}
}

View 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;
}
}

View 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());
}
}
}

View 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
];
}
}

View File

@@ -13,14 +13,20 @@ define('STORAGE_PATH', ROOT_PATH . '/storage');
// 2. Load Environment & Helpers FIRST
require_once APP_PATH . '/bootstrap/env.php';
require_once APP_PATH . '/helpers/helpers.php';
require_once APP_PATH . '/helpers/pagination.php';
// Load Composer Autoloader
$vendorAutoload = ROOT_PATH . '/vendor/autoload.php';
if (file_exists($vendorAutoload)) {
require_once $vendorAutoload;
}
// Self-healing Storage
$dirs = ['/cache', '/logs', '/invoices', '/exports'];
foreach ($dirs as $d) {
$path = STORAGE_PATH . $d;
if (!is_dir($path)) {
mkdir($path, 0777, true);
chmod($path, 0777);
mkdir($path, 0755, true);
}
}
@@ -60,7 +66,27 @@ header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: SAMEORIGIN");
header("X-XSS-Protection: 1; mode=block");
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)
spl_autoload_register(function ($class) {

View File

@@ -9,12 +9,14 @@
return [
'free' => [
'id' => 'free',
'name_ar' => 'مجانية',
'name_en' => 'Free',
'name_ar' => 'التجربة المجانية',
'name_en' => 'Free Trial',
'max_companies' => 1,
'max_invoices_month' => 15,
'max_users' => 1,
'price_jod' => 0.00,
'price_monthly_jod' => 0.00,
'price_annual_jod' => 0.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'gray',
@@ -29,90 +31,50 @@ return [
],
'basic' => [
'id' => 'basic',
'name_ar' => 'أساسية',
'name_en' => 'Basic',
'name_ar' => 'الباقة الأساسية',
'name_en' => 'Basic Plan',
'max_companies' => 3,
'max_invoices_month' => 100,
'max_users' => 3,
'price_jod' => 15.00,
'max_invoices_month' => 500,
'max_users' => 2,
'price_jod' => 15.00, // Default legacy price
'price_monthly_jod' => 15.00,
'price_annual_jod' => 120.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'blue',
'description_ar' => 'للمحاسبين المستقلين — 3 شركات',
'description_ar' => 'للمحاسبين المستقلين والشركات الصغيرة — 3 شركات',
'features' => [
'استخراج الفواتير بالذكاء الاصطناعي',
'الربط المباشر مع جوفوترة',
'حتى 3 شركات',
'100 فاتورة شهرياً',
'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 مستخدمين',
'تقارير متقدمة + تصدير',
'دعم فني بالأولوية',
'حتى 3 شركات (بدلاً من واحدة)',
'500 فاتورة شهرياً (سخية جداً)',
'مستخدمين اثنين',
'دعم فني عبر الواتساب',
],
],
'pro' => [
'id' => 'pro',
'name_ar' => 'احترافية',
'name_en' => 'Pro',
'max_companies' => 25,
'max_invoices_month' => 2000,
'max_users' => 25,
'price_jod' => 99.00,
'ai_features' => true,
'jofotara_enabled' => true,
'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,
'name_ar' => 'الباقة الاحترافية',
'name_en' => 'Pro Plan',
'max_companies' => 9999,
'max_invoices_month' => 3000,
'max_users' => 5,
'price_jod' => 35.00, // Default legacy price
'price_monthly_jod' => 35.00,
'price_annual_jod' => 290.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'gold',
'description_ar' => 'للمؤسسات — بلا حدود مع دعم مخصص',
'is_popular' => true,
'description_ar' => 'للمكاتب الكبيرة والموزعين — حجم عمل ضخم',
'features' => [
'كل ميزات الاحترافية',
'شركات وفواتير بلا حدود عملية',
'مستخدمين بلا حدود',
'SLA مضمون 99.9%',
'ربط API مخصص',
'تدريب فريق المحاسبة',
'نسخ احتياطي مخصص',
'استخراج الفواتير بالذكاء الاصطناعي',
'الربط المباشر مع جوفوترة',
'عدد شركات غير محدود',
'3,000 فاتورة شهرياً',
'5 مستخدمين',
'API كامل لتطبيق الهاتف',
'مدير حساب مخصص',
],
],
];

139
app/cron/diagnose.php Normal file
View 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";

View 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);
}

View File

@@ -38,3 +38,19 @@ if (!function_exists('dd')) {
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);
}
}

View 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);
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\Middleware;
use App\Core\Database;
use App\Core\Cache;
final class QuotaMiddleware
{
@@ -22,17 +23,26 @@ final class QuotaMiddleware
*/
public static function checkInvoiceQuota(string $tenantId): array
{
$db = Database::getInstance();
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub === false || $sub === null) {
$db = Database::getInstance();
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub) {
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
}
}
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
@@ -47,10 +57,12 @@ final class QuotaMiddleware
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()) {
$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("
UPDATE subscriptions
@@ -66,15 +78,15 @@ final class QuotaMiddleware
$sub['current_period_start'] = $newStart;
$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
$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) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
@@ -100,6 +112,9 @@ final class QuotaMiddleware
WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
// Invalidate cache
Cache::delete("quota_sub_{$tenantId}");
}
/**
@@ -230,6 +245,11 @@ final class QuotaMiddleware
$companiesLimit = (int)$sub['max_companies'];
$usersLimit = (int)($sub['max_users'] ?? 999);
// Check for pending payment request
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$pendingPayment = $stmt->fetch();
return [
'has_subscription' => true,
'plan_id' => $sub['plan_id'] ?? 'free',
@@ -239,6 +259,11 @@ final class QuotaMiddleware
'status' => $sub['status'],
'ai_features' => (bool)($sub['ai_features'] ?? false),
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
'pending_payment' => $pendingPayment ? [
'id' => $pendingPayment['id'],
'plan_id' => $pendingPayment['plan_id'],
'reference' => $pendingPayment['internal_reference']
] : null,
'invoices' => [
'used' => $invoicesUsed,

View File

@@ -15,54 +15,61 @@ final class RateLimitMiddleware
*/
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';
$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+');
if ($fp === false) {
// If we can't open the file, fail open (don't block all users)
return;
}
if ($fp === false) return;
try {
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
$now = time();
$content = stream_get_contents($fp);
flock($fp, LOCK_EX);
$now = time();
$content = stream_get_contents($fp);
$requests = [];
if (!empty($content)) {
$decoded = json_decode($content, true);
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) {
flock($fp, LOCK_UN);
fclose($fp);
header('Retry-After: ' . $timeWindow);
json_error('Too Many Requests. Please slow down.', 429);
}
// Record this request
$requests[] = $now;
// Write updated data back
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($requests));
} finally {
flock($fp, LOCK_UN);
fclose($fp);

View 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),
], 'أكاديمية مُصادَق');

View 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),
],
]);

View 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,
]);

View 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', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.');
}

View 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');
}

View 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', 'خطأ في جلب سجل النشاط.');
}

View File

@@ -39,38 +39,140 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
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');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
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 = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
'device_id' => $deviceId,
'source' => $deviceId ? 'mobile' : 'web',
'exp' => time() + $expiry
];
$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));
$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']]);
// 7. Secure Refresh Token delivery via HttpOnly Cookie
setcookie('refresh_token', $refreshToken, [
'expires' => time() + (7 * 24 * 60 * 60), // 7 days
'path' => '/api/v1/auth/refresh',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
if (!$deviceId) {
setcookie('refresh_token', $refreshToken, [
'expires' => time() + (7 * 24 * 60 * 60), // 7 days
'path' => '/api/v1/auth/refresh',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
}
json_success([
'access_token' => $token,
'refresh_token' => $refreshToken,
'device_secret' => $deviceSecret,
'user' => [
'id' => $user['id'],
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),

View File

@@ -17,121 +17,105 @@ use App\Middleware\RateLimitMiddleware;
// Rate limit: 3 OTP requests per minute per IP
RateLimitMiddleware::check(3, 60);
$data = Security::sanitize(input());
try {
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'phone' => 'required',
]);
// 1. Validate
$errors = Validator::validate($data, [
'phone' => 'required',
]);
if ($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;
if ($errors) {
json_error('رقم الهاتف مطلوب', 422, $errors);
}
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$phoneHash = hash('sha256', $phone);
// 2. Find user by phone hash OR plain phone (Support both schemas)
$db = Database::getInstance();
// First, try to find by phone_hash. If it fails, we'll catch it.
try {
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone_hash = ? LIMIT 1");
$stmt->execute([$phoneHash]);
$user = $stmt->fetch();
} catch (\PDOException $e) {
try {
// Fallback to searching by plain phone if phone_hash column doesn't exist
$stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone = ? LIMIT 1");
$stmt->execute([$phone]);
$user = $stmt->fetch();
} catch (\PDOException $fallbackException) {
json_error('حدث خطأ في قاعدة البيانات: ' . $fallbackException->getMessage(), 500);
}
}
if (!$user) {
// Don't reveal if phone exists — generic message
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق');
exit;
}
if (!$user['is_active']) {
json_error('الحساب معطّل. تواصل مع المسؤول.', 403);
}
// 3. Generate OTP (6 digits)
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
$otpHash = password_hash($otp, PASSWORD_DEFAULT);
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
// 4. Store OTP in database (or Redis if available)
$cacheDir = STORAGE_PATH . '/cache/otp';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$otpData = [
'hash' => $otpHash,
'user_id' => $user['id'],
'attempts' => 0,
'max_attempts' => 5,
'expires_at' => time() + 300,
'created_at' => time(),
];
$fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w');
if ($fp) {
flock($fp, LOCK_EX);
fwrite($fp, json_encode($otpData));
flock($fp, LOCK_UN);
fclose($fp);
}
// 5. Send OTP via WhatsApp Proxy
$whatsappService = new \App\Services\WhatsAppProxyService();
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
$result = $whatsappService->sendMessage($phone, $message);
if (!$result['success']) {
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500, ['whatsapp_debug' => $result]);
}
// Log for development (REMOVE IN PRODUCTION!)
if (env('APP_DEBUG', 'false') === 'true') {
error_log("DEV OTP for {$phone}: {$otp}");
}
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
} catch (\Exception $e) {
safe_error($e, 'auth/mobile_request_otp');
}

View File

@@ -30,6 +30,13 @@ if ($errors) {
}
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$phoneHash = hash('sha256', $phone);
$deviceId = $data['device_id'] ?? '';
$deviceName = $data['device_name'] ?? 'Unknown Device';
@@ -168,6 +175,7 @@ json_success([
'user' => [
'id' => $user['id'],
'name' => $userName,
'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
'role' => $user['role'],
'tenant_id' => $user['tenant_id'],
],

View File

@@ -36,18 +36,29 @@ $expectedImages = (int)($data['expected_images'] ?? 0);
// 2. Permission check
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
$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);
}
// Use the actual tenant of the company
$targetTenantId = $company['tenant_id'];
// 3. Check quota (preview — don't increment yet)
try {
QuotaMiddleware::checkInvoiceQuota($tenantId);
} catch (\Exception $e) {
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
if ($decoded['role'] !== 'super_admin') {
try {
QuotaMiddleware::checkInvoiceQuota($targetTenantId);
} catch (\Exception $e) {
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
}
}
// 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)
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
");
$stmt->execute([$batchId, $tenantId, $companyId, $userId, $expectedImages, $source]);
$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $expectedImages, $source]);
// 6. Create upload directory
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}

View File

@@ -4,7 +4,7 @@
* POST /v1/batches/finalize
*
* 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);
@@ -12,6 +12,7 @@ declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
use App\Services\InvoiceProcessor;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
@@ -28,14 +29,14 @@ $db = Database::getInstance();
// 1. Verify batch
$stmt = $db->prepare("
SELECT id, status, total_images
SELECT id, tenant_id, status, total_images
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();
if (!$batch) {
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة', 404);
}
@@ -55,11 +56,87 @@ $stmt = $db->prepare("
");
$stmt->execute([$batchId]);
// In a real production environment, you would dispatch a job to a queue worker here.
// For now, the queue worker is a cron job that checks the `invoice_processing_queue` table.
// 3. Send response IMMEDIATELY to mobile app
// We manually build the response instead of using json_success() because it calls exit()
$responsePayload = json_encode([
'success' => true,
'data' => [
'batch_id' => $batchId,
'status' => 'processing',
'total_images' => $batch['total_images']
],
'message' => 'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية',
'timestamp' => date('c')
], JSON_UNESCAPED_UNICODE);
json_success([
'batch_id' => $batchId,
'status' => 'processing',
'total_images' => $batch['total_images']
], 'تم إنهاء الدفعة بنجاح وإرسالها للمعالجة');
// Set headers
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($responsePayload));
http_response_code(200);
// Flush ALL output buffers to send response to client NOW
echo $responsePayload;
// Flush PHP output buffers
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
// Log the API call for app.log (mimicking json_response behavior)
$logEntry = sprintf(
"API %s %s | 200 | OK | %s",
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
$_SERVER['REQUEST_URI'] ?? '',
'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية'
);
error_log($logEntry);
@file_put_contents(
STORAGE_PATH . '/logs/app.log',
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
FILE_APPEND
);
// 4. Tell PHP-FPM: "The client response is done. But keep this PHP process alive."
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// 5. Now process in the background (client has already received the response)
ignore_user_abort(true);
set_time_limit(300); // 5 minutes max
$bgLog = function(string $msg) {
@file_put_contents(
STORAGE_PATH . '/logs/worker.log',
"[" . date('Y-m-d H:i:s') . "] [finalize-bg] " . $msg . "\n",
FILE_APPEND
);
};
$bgLog("Background processing started for batch: $batchId");
try {
$queueStmt = $db->prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending' ORDER BY created_at ASC");
$queueStmt->execute([$batchId]);
$items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN);
$bgLog("Found " . count($items) . " pending item(s) for batch $batchId");
foreach ($items as $queueId) {
$bgLog("Processing queue item: $queueId");
try {
$success = InvoiceProcessor::processQueueItem((int)$queueId);
$bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED"));
} catch (\Throwable $e) {
$bgLog("Queue item $queueId EXCEPTION: " . $e->getMessage());
}
}
$bgLog("Background processing finished for batch: $batchId");
} catch (\Throwable $e) {
$bgLog("FATAL ERROR in background processing: " . $e->getMessage());
}
exit;

View 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";

View File

@@ -27,14 +27,14 @@ $db = Database::getInstance();
// 1. Get batch info
$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
WHERE id = ? AND tenant_id = ?
WHERE id = ?
");
$stmt->execute([$batchId, $tenantId]);
$stmt->execute([$batchId]);
$batch = $stmt->fetch();
if (!$batch) {
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة', 404);
}

View File

@@ -25,29 +25,32 @@ if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOA
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();
$stmt = $db->prepare("
SELECT id, company_id, status, total_images
SELECT id, tenant_id, company_id, status, total_images
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();
if (!$batch) {
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
}
// Override tenantId with the actual batch's tenantId
$tenantId = $batch['tenant_id'];
if ($batch['status'] !== 'uploading') {
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
}
// 3. Validate file type
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
$mimeType = $_FILES['image']['type'];
if (!in_array($mimeType, $allowedTypes)) {
json_error('نوع الملف غير مدعوم. المسموح: JPEG, PNG, WebP, HEIC', 422);
json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422);
}
// 4. Validate file size (max 10MB)

View 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', 'حدث خطأ في المساعد الذكي.');
}

View 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);

View File

@@ -61,5 +61,5 @@ try {
} catch (\Exception $e) {
error_log("JoFotara Connection Error: " . $e->getMessage());
json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500);
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
}

View File

@@ -6,12 +6,11 @@
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('Unauthorized', 403);
}
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$data = input();
@@ -80,9 +79,16 @@ try {
]);
$db->commit();
AuditLogger::log('company.created', 'company', null, null, [
'name' => $data['name'],
'tin' => $data['tax_identification_number'],
], $decoded);
json_success(null, 'تم إنشاء الشركة بنجاح');
} catch (\Exception $e) {
$db->rollBack();
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
error_log("[companies/create] Error: " . $e->getMessage());
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
}

View File

@@ -4,9 +4,12 @@
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
use App\Middleware\CompanyAccessMiddleware;
$decoded = AuthMiddleware::check();
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$companyId = input('id');
@@ -28,12 +31,13 @@ if (!$company) {
json_error('الشركة غير موجودة', 404);
}
if ($decoded['role'] === 'admin' && $company['tenant_id'] !== $decoded['tenant_id']) {
json_error('ليس لديك صلاحية لحذف هذه الشركة', 403);
}
// Verify tenant access (admin can only delete from their tenant)
CompanyAccessMiddleware::check($companyId, $decoded);
// Soft Delete
$stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?");
$stmt->execute([$companyId]);
AuditLogger::log('company.deleted', 'company', $companyId, null, null, $decoded);
json_success(null, 'تم حذف الشركة بنجاح');

View File

@@ -64,5 +64,5 @@ try {
json_success($companies);
} catch (\Exception $e) {
json_error('SQL Error in Companies List: ' . $e->getMessage(), 500);
safe_error($e, 'companies/index');
}

View 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, 'تم تحديث بيانات الشركة بنجاح');

View 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', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.');
}

View 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 '';
}

View File

@@ -15,38 +15,67 @@ $companyId = $decoded['company_id'] ?? null;
$role = $decoded['role'];
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') {
// No filters - see everything
$where = "WHERE 1=1";
$params = [];
} elseif ($role === 'accountant' || $role === 'viewer') {
$where = "WHERE tenant_id = ? AND company_id = ?";
$params = [$tenantId, $companyId];
} else {
// Tenant Users (Admin, Accountant, Employee): Filter by Tenant
// admin
$where = "WHERE tenant_id = ?";
$params = [$tenantId];
}
// 3. Fetch Stats
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where");
$stmt->execute($params);
$total = $stmt->fetchColumn();
$stats['invoices']['total'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'");
$stmt->execute($params);
$pending = $stmt->fetchColumn();
$stats['invoices']['pending'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
$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) {
$total = 0;
$pending = 0;
$approved = 0;
// Return default zeroed stats on error
}
json_success([
'total' => $total,
'pending' => $pending,
'approved' => $approved
]);
json_success($stats);

View 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');
}

View 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, 'ملفك التنافسي');

View File

@@ -5,9 +5,13 @@
use App\Core\Database;
use App\Core\JoFotara;
use App\Core\AuditLogger;
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();
$data = json_decode(file_get_contents('php://input'), true);
@@ -111,8 +115,29 @@ try {
'is_api_success' => $apiResponse['success']
]);
AuditLogger::log('invoice.approved', 'invoice', $id, [
'old_status' => $invoice['status'],
], [
'new_status' => 'approved',
'jofotara_uuid' => $apiResponse['uuid'] ?? null,
'api_success' => $apiResponse['success'],
], $decoded);
// Smart Notifications
\App\Services\SmartNotifications::invoiceApproved(
$invoice['tenant_id'], $invoice['uploaded_by'] ?? $decoded['user_id'],
$id, $invoice['invoice_number'] ?? $id
);
\App\Services\SmartNotifications::checkQuotaWarning($invoice['tenant_id']);
// Gamification
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'invoice_approved');
if ($apiResponse['success'] ?? false) {
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'jofotara_submitted');
}
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("JoFotara Approve Error: " . $e->getMessage());
json_error('خطأ غير متوقع: ' . $e->getMessage(), 500);
safe_error($e, 'invoices/approve');
}

View 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 فاتورة بنجاح");

View 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) ? 'لا توجد فواتير مكررة' : 'تم العثور على فواتير مشابهة');

View 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);
}

View 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;

View 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());
}

View File

@@ -19,15 +19,14 @@ function outputErrorImage($message) {
exit;
}
// Extract token from header OR query string
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
$token = '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
} elseif (isset($_GET['token'])) {
$token = $_GET['token'];
// Extract token from header OR query string using helper
$token = input('token');
if (!$token) {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
}
if (!$token) outputErrorImage('Forbidden: No token');

View File

@@ -1,6 +1,6 @@
<?php
/**
* Invoices List Endpoint (Role-Based & Tenant-Aware)
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
*/
use App\Core\Database;
@@ -16,26 +16,17 @@ $userId = $decoded['user_id'];
$role = $decoded['role'];
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') {
// Super Admin sees ALL invoices
$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
");
$where = '1=1';
} elseif ($role === 'admin') {
// Admin sees all invoices in THEIR tenant
$stmt = $db->prepare("
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]);
$where = 'i.tenant_id = ?';
$params = [$tenantId];
} else {
// Accountant/Viewer: Filter by assigned companies
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
@@ -43,26 +34,58 @@ try {
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
if (empty($assignedCompanyIds)) {
json_success([]);
json_paginated([], 0, $pagination);
}
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
$stmt = $db->prepare("
SELECT i.*, c.name as company_name
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);
$where = "i.company_id IN ($placeholders)";
$params = $assignedCompanyIds;
}
// Optional filters from query string
$companyFilter = $_GET['company_id'] ?? null;
$statusFilter = $_GET['status'] ?? null;
$searchFilter = $_GET['search'] ?? null;
if ($companyFilter) {
$where .= ' AND i.company_id = ?';
$params[] = $companyFilter;
}
if ($statusFilter) {
$where .= ' AND i.status = ?';
$params[] = $statusFilter;
}
if ($searchFilter) {
$where .= ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ?)';
$params[] = "%$searchFilter%";
$params[] = "%$searchFilter%";
}
// 3. Count total
$countStmt = $db->prepare("SELECT COUNT(*) FROM invoices i WHERE $where");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
// 4. Fetch page
$joinTenant = ($role === 'super_admin') ? 'LEFT JOIN tenants t ON i.tenant_id = t.id' : '';
$selectTenant = ($role === 'super_admin') ? ', t.name as tenant_name' : '';
$stmt = $db->prepare("
SELECT i.*{$selectTenant}, c.name as company_name
FROM invoices i
LEFT JOIN companies c ON i.company_id = c.id
{$joinTenant}
WHERE {$where}
ORDER BY i.created_at DESC
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
");
$stmt->execute($params);
$invoices = $stmt->fetchAll();
// 3. Decrypt sensitive fields for display (Robustly)
// 5. Decrypt sensitive fields
$dec = function($val) {
if (empty($val)) return '';
$result = \App\Core\Encryption::decrypt((string)$val);
$result = Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
@@ -79,12 +102,8 @@ try {
}
}
if (empty($invoices)) {
error_log("INVOICES LIST: No invoices found for role: $role, tenant_id: $tenantId");
}
json_success($invoices);
json_paginated($invoices, $total, $pagination);
} catch (\Exception $e) {
json_error('SQL Error in Invoices List: ' . $e->getMessage(), 500);
safe_error($e, 'invoices/index');
}

View 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);
}

View 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);
}

View 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', 'فشل تحديث الفاتورة.');
}

View File

@@ -19,18 +19,20 @@ try {
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
// --- QUOTA CHECK ---
QuotaMiddleware::checkInvoiceQuota($tenantId);
// -------------------
$db = Database::getInstance();
$allowedRoles = ['admin', 'accountant', 'employee'];
$allowedRoles = ['super_admin', 'admin', 'accountant', 'employee'];
if (!in_array($decoded['role'], $allowedRoles)) {
json_error('غير مصرح لك برفع الفواتير', 403);
exit;
}
// --- QUOTA CHECK (skip for super_admin ONLY) ---
if ($decoded['role'] !== 'super_admin') {
QuotaMiddleware::checkInvoiceQuota($tenantId);
}
// -------------------
$db = Database::getInstance();
// 2. Validate Request
// استخدام $_POST للتعامل الآمن مع multipart/form-data
$companyId = $_POST['company_id'] ?? null;
@@ -46,14 +48,25 @@ try {
}
// 3. Permission Check
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
if ($decoded['role'] === 'super_admin') {
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId]);
} else {
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
}
if (!$stmt->fetch()) {
$company = $stmt->fetch();
if (!$company) {
json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403);
exit;
}
// لضمان حفظ الفاتورة في المكتب الصحيح إذا كان المرفوع سوبر أدمن
if ($decoded['role'] === 'super_admin') {
$tenantId = $company['tenant_id'];
}
// 4. Handle File Upload
$tenantDir = STORAGE_PATH . '/invoices/' . $tenantId;
$companyDir = $tenantDir . '/' . $companyId;
@@ -62,11 +75,12 @@ try {
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
if (!is_dir($dir)) {
if (!mkdir($dir, 0777, true)) {
json_error('فشل في إنشاء مجلد التخزين: ' . $dir, 500);
if (!mkdir($dir, 0755, true)) {
error_log('Failed to create storage directory: ' . $dir);
json_error('فشل في تجهيز مساحة التخزين', 500);
exit;
}
chmod($dir, 0777);
chmod($dir, 0755);
}
}
@@ -103,118 +117,141 @@ try {
// 6. Save Extracted Data
$db->beginTransaction();
$supplierTin = $extracted['supplier']['tin'] ?? '';
$invoiceNum = $extracted['invoice_number'] ?? '';
$invoiceDate = $extracted['invoice_date'] ?? '';
$extractedInvoices = $extracted['invoices'] ?? [$extracted];
$savedIds = [];
$invoiceHash = null;
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
$invoiceHash = hash('sha256', strtolower($rawHashString));
foreach ($extractedInvoices as $inv) {
$supplierTin = $inv['supplier']['tin'] ?? '';
$invoiceNum = $inv['invoice_number'] ?? '';
$invoiceDate = $inv['invoice_date'] ?? '';
$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()) {
$db->rollBack();
json_error('هذه الفاتورة تم رفعها مسبقاً لهذه الشركة (رقم الفاتورة مكرر لنفس المورد والتاريخ).', 409);
exit;
$invoiceHash = null;
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
$invoiceHash = hash('sha256', strtolower($rawHashString));
$checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL");
$checkStmt->execute([$companyId, $invoiceHash]);
if ($checkStmt->fetch()) {
continue; // Skip duplicates in multi-page files
}
}
}
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$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)
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
$subtotal = is_numeric($extracted['subtotal'] ?? null) ? $extracted['subtotal'] : 0;
$tax = is_numeric($extracted['tax_amount'] ?? null) ? $extracted['tax_amount'] : 0;
$disc = is_numeric($extracted['discount_total'] ?? null) ? $extracted['discount_total'] : 0;
$total = is_numeric($extracted['grand_total'] ?? null) ? $extracted['grand_total'] : 0;
$stmt = $db->prepare("
INSERT INTO invoices (
id, tenant_id, company_id, uploaded_by, original_file_path, status,
invoice_number, invoice_date, invoice_type, invoice_category,
supplier_tin, supplier_name, supplier_address,
buyer_tin, buyer_name, buyer_national_id,
subtotal, tax_amount, discount_total, grand_total, currency_code,
invoice_hash, validation_warnings,
created_at
) VALUES (
:id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted',
:num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid,
:sub, :tax, :disc, :total, :cur,
:hash, :warnings,
NOW()
)
");
$stmt->execute([
'id' => $invoiceId,
'tenant_id' => $tenantId,
'company_id' => $companyId,
'uploaded_by' => $userId,
'path' => $targetFile,
'num' => !empty($invoiceNum) ? $invoiceNum : null,
'date' => $validDate,
'type' => !empty($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 (?, ?, ?, ?, ?, ?, ?, ?)
$stmt = $db->prepare("
INSERT INTO invoices (
id, tenant_id, company_id, uploaded_by, original_file_path, status,
invoice_number, invoice_date, invoice_type, invoice_category,
supplier_tin, supplier_name, supplier_address,
buyer_tin, buyer_name, buyer_national_id,
subtotal, tax_amount, discount_total, grand_total, currency_code,
invoice_hash, validation_warnings,
created_at
) VALUES (
:id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted',
:num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid,
:sub, :tax, :disc, :total, :cur,
:hash, :warnings,
NOW()
)
");
foreach ($extracted['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
]);
$stmt->execute([
'id' => $invoiceId,
'tenant_id' => $tenantId,
'company_id' => $companyId,
'uploaded_by' => $userId,
'path' => $targetFile,
'num' => !empty($invoiceNum) ? $invoiceNum : null,
'date' => $validDate,
'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash',
'cat' => !empty($inv['invoice_category']) ? $inv['invoice_category'] : 'simplified',
's_tin' => Encryption::encrypt($supplierTin),
's_name' => Encryption::encrypt($inv['supplier']['name'] ?? ''),
's_addr' => Encryption::encrypt($inv['supplier']['address'] ?? ''),
'b_tin' => Encryption::encrypt($inv['buyer']['tin'] ?? ''),
'b_name' => Encryption::encrypt($inv['buyer']['name'] ?? ''),
'b_nid' => Encryption::encrypt($inv['buyer']['national_id'] ?? ''),
'sub' => $subtotal,
'tax' => $tax,
'disc' => $disc,
'total' => $total,
'cur' => !empty($inv['currency_code']) ? $inv['currency_code'] : 'JOD',
'hash' => $invoiceHash,
'warnings' => !empty($inv['validation_warnings']) ? json_encode($inv['validation_warnings']) : null
]);
// Save Line Items
if (!empty($inv['lines']) && is_array($inv['lines'])) {
$lineStmt = $db->prepare("
INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
foreach ($inv['lines'] as $index => $item) {
$lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$lineStmt->execute([
$lineId,
$invoiceId,
$item['line_number'] ?? ($index + 1),
$item['description'] ?? 'بدون وصف',
is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1,
is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0,
is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16,
is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0
]);
}
}
$savedIds[] = $invoiceId;
if ($decoded['role'] !== 'super_admin') {
QuotaMiddleware::incrementInvoiceUsage($tenantId);
}
}
$db->commit();
// --- INCREMENT QUOTA ---
QuotaMiddleware::incrementInvoiceUsage($tenantId);
if (empty($savedIds)) {
json_error('لم يتم حفظ أي فواتير جديدة من هذا الملف (قد تكون مكررة)', 409);
exit;
}
// --- NOTIFICATIONS & GAMIFICATION (for first invoice only for simplicity) ---
\App\Services\SmartNotifications::checkQuotaWarning($tenantId);
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
// -----------------------
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;
} catch (\PDOException $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log("Database Error: " . $e->getMessage());
json_error('حدث خطأ في قاعدة البيانات: ' . $e->getMessage(), 500);
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
exit;
} catch (\Throwable $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log("Critical Error: " . $e->getMessage() . " on line " . $e->getLine());
json_error('خطأ برمجي حرج: ' . $e->getMessage() . ' في السطر ' . $e->getLine(), 500);
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
exit;
}

View 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("خطأ في النظام");
}

View File

@@ -91,8 +91,13 @@ try {
$invoice['jofotara'] = null;
}
// 5. Build the secure file URL using the invoice ID (file.php fetches path from DB)
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . urlencode($id);
// 5. Build the secure file URL with token (for Image.network compatibility)
$authHeader = getallheaders()['Authorization'] ?? getallheaders()['authorization'] ?? '';
$token = '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . urlencode($id) . '&token=' . $token;
// 6. Include local QR code from invoices table if available
// (This is used as a fallback in shell.php if jofotara object is missing)

View 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', 'حدث خطأ في تحميل القوائم.');
}

View 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', 'حدث خطأ في حفظ القائمة.');
}

View 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),
],
]);

View 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);
}

View 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'])
]);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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])
]);
}

View 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])
]);
}

View 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', 'حدث خطأ في تطبيق رمز الإحالة.');
}

View 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', 'حدث خطأ في نظام الإحالة.');
}

View 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', 'حدث خطأ في إنشاء التقرير.');
}

View 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,
], 'تقرير ضريبة المبيعات الشهري');

View 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' => 'خطأ أثناء تأكيد الدفعة'];
}
}

View File

@@ -91,5 +91,5 @@ try {
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Subscription Assign Error: " . $e->getMessage());
json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500);
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
}

View File

@@ -13,7 +13,7 @@ $db = Database::getInstance();
try {
$stmt = $db->query("
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
WHERE is_active = 1
ORDER BY sort_order ASC
@@ -36,6 +36,8 @@ try {
$plan['max_invoices_month'] = (int)$plan['max_invoices_month'];
$plan['max_users'] = (int)$plan['max_users'];
$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['jofotara_enabled'] = (bool)$plan['jofotara_enabled'];
}

View File

@@ -18,8 +18,8 @@ $data = input();
$errors = Validator::validate($data, [
'name' => 'required',
'email' => 'required|email',
'phone' => 'required',
'manager_name' => 'required',
'manager_email' => 'required|email',
'manager_password' => 'required'
]);
@@ -43,12 +43,23 @@ try {
$encryptedTenantName = \App\Core\Encryption::encrypt($data['name']);
$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->execute([
$tenantId,
$encryptedTenantName,
$encryptedTenantEmail,
$data['phone'] ?? null
$phone
]);
// Generate User UUID
@@ -60,17 +71,19 @@ try {
// Encrypt sensitive user data
$encryptedName = \App\Core\Encryption::encrypt($data['manager_name']);
$encryptedEmail = \App\Core\Encryption::encrypt($data['manager_email']);
$emailHash = hash('sha256', strtolower($data['manager_email']));
$encryptedEmail = \App\Core\Encryption::encrypt($data['email']);
$emailHash = hash('sha256', strtolower($data['email']));
// 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([
$userId,
$tenantId,
$encryptedName,
$encryptedEmail,
$emailHash,
$encryptedPhone,
$phoneHash,
password_hash($data['manager_password'], PASSWORD_DEFAULT)
]);
@@ -78,6 +91,6 @@ try {
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
} catch (\Exception $e) {
$db->rollBack();
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.');
}

View 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, 'تم حذف المكتب المحاسبي بنجاح');

View File

@@ -18,22 +18,29 @@ try {
$stmt = $db->query("
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 users WHERE tenant_id = t.id) as users_count,
(SELECT COUNT(*) FROM invoices WHERE tenant_id = t.id) as invoices_count
FROM tenants t
ORDER BY t.created_at DESC
");
$tenants = $stmt->fetchAll();
foreach ($tenants as &$t) {
$decName = \App\Core\Encryption::decrypt($t['name']);
$t['name'] = $decName !== false ? $decName : $t['name'];
$dec = function($val) {
if (empty($val)) return '';
$result = \App\Core\Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
$decEmail = \App\Core\Encryption::decrypt($t['email']);
$t['email'] = $decEmail !== false ? $decEmail : $t['email'];
foreach ($tenants as &$t) {
$t['name'] = $dec($t['name']);
$t['email'] = $dec($t['email']);
if (!empty($t['phone'])) {
$t['phone'] = $dec($t['phone']);
}
}
json_success($tenants);
} catch (\Exception $e) {
json_error('SQL Error in Tenants List: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/index');
}

View File

@@ -56,5 +56,5 @@ try {
]);
} catch (\Exception $e) {
json_error('Stats Error: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/stats');
}

View File

@@ -59,5 +59,5 @@ try {
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
} catch (\Exception $e) {
json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.');
}

View File

@@ -6,13 +6,12 @@
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
// 1. Auth Check (Only super_admin or admin can create users)
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('Unauthorized', 403);
}
// 1. Auth + Role Check (Only super_admin or admin can create users)
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$data = input();
@@ -31,7 +30,8 @@ if (!in_array($data['role'] ?? '', $allowedRoles, true)) {
$errors = Validator::validate($data, [
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
'phone' => 'required',
'password' => 'required|strong_password',
'role' => 'required'
]);
@@ -46,6 +46,17 @@ $encryptedName = Encryption::encrypt($data['name']);
$encryptedEmail = Encryption::encrypt($data['email']);
$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
$tenantId = null;
if ($decoded['role'] === 'super_admin') {
@@ -63,19 +74,27 @@ if ($decoded['role'] === 'super_admin') {
// 4. Save to Database
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([
\App\Core\Database::generateUuid(),
$tenantId,
$encryptedName,
$encryptedEmail,
$emailHash,
$encryptedPhone,
$phoneHash,
password_hash($data['password'], PASSWORD_DEFAULT),
$data['role'],
date('Y-m-d H:i:s')
]);
json_success(null, 'تم إضافة المستخدم بنجاح');
AuditLogger::log('user.created', 'user', null, null, [
'name' => $data['name'],
'email' => $data['email'],
'role' => $data['role'],
], $decoded);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
json_error('البريد الإلكتروني مسجل مسبقاً', 409);

View File

@@ -4,10 +4,12 @@
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
// 1. Auth + Role Check
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$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->execute([$targetUserId]);
AuditLogger::log('user.deleted', 'user', $targetUserId, [
'role' => $targetUser['role'],
], null, $decoded);
json_success(null, 'تم حذف المستخدم بنجاح');

View File

@@ -1,6 +1,6 @@
<?php
/**
* Users List Endpoint (Role-Based & Tenant-Aware)
* Users List Endpoint (Role-Based, Tenant-Aware, Paginated)
*/
use App\Core\Database;
@@ -14,55 +14,74 @@ $db = Database::getInstance();
$role = $decoded['role'];
$tenantId = $decoded['tenant_id'] ?? null;
if ($role !== 'super_admin' && $role !== 'admin') {
json_error('Unauthorized', 403);
}
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') {
// Super Admin sees ALL users from ALL tenants
$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]);
$where = '1=1';
} else {
// Other roles shouldn't see user list
json_error('Unauthorized', 403);
$where = 'u.tenant_id = ?';
$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();
// 3. Decrypt data and format
// 5. Decrypt data
$dec = function($val) {
if (empty($val)) return '';
$result = \App\Core\Encryption::decrypt((string)$val);
$result = Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
foreach ($users as &$user) {
$user['name'] = $dec($user['name']);
$user['email'] = $dec($user['email']);
if (!empty($user['phone'])) {
$user['phone'] = $dec($user['phone']);
}
if (!empty($user['tenant_name'])) {
$user['tenant_name'] = $dec($user['tenant_name']);
}
}
if (empty($users)) {
error_log("USERS LIST: No users found for role: $role, tenant_id: $tenantId");
}
json_success($users);
json_paginated($users, $total, $pagination);
} catch (\Exception $e) {
json_error('SQL Error in Users List: ' . $e->getMessage(), 500);
safe_error($e, 'users/index');
}

View 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, 'تم تحديث بيانات المستخدم بنجاح');

View 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);
}

View File

@@ -1,18 +1,28 @@
<?php
/**
* Voice Transcribe Proxy Endpoint
* Voice Command Endpoint (Gemini Audio → Intent → Internal Execution)
* 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);
set_time_limit(90);
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
use App\Middleware\QuotaMiddleware;
use App\Middleware\RateLimitMiddleware;
// Rate limit: 20 per minute
RateLimitMiddleware::check(20, 60);
// Rate limit: 15 voice requests per minute
RateLimitMiddleware::check(15, 60);
$decoded = AuthMiddleware::check();
@@ -20,42 +30,614 @@ if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
json_error('ملف الصوت مطلوب', 422);
}
$apiKey = env('GROQ_API_KEY');
if (!$apiKey) {
json_error('Groq API Key غير متوفر', 500);
$audio = $_FILES['audio'];
$maxBytes = 10 * 1024 * 1024; // 10MB inline-safe
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)
$tmpPath = $_FILES['audio']['tmp_name'];
$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);
$geminiApiKey = env('GEMINI_API_KEY');
if (!$geminiApiKey) {
json_error('Gemini API Key غير متوفر', 500);
}
$data = json_decode($response, true);
json_success(['text' => $data['text'] ?? ''], 'تم التحويل بنجاح');
$tmpPath = $audio['tmp_name'];
$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;
}

View 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', 'حدث خطأ في إنشاء كود الربط.');
}

View 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');
}

View File

@@ -21,7 +21,8 @@
"guzzlehttp/guzzle": "^7.9",
"respect/validation": "^2.3",
"league/flysystem": "^3.28",
"symfony/mailer": "^7.1"
"symfony/mailer": "^7.1",
"phpoffice/phpspreadsheet": "^2.1"
},
"require-dev": {
"phpunit/phpunit": "^11.0",

View 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