Compare commits

...

212 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
Hamza-Ayed
05eba6adfb Update: 2026-05-06 01:40:53 2026-05-06 01:40:53 +03:00
Hamza-Ayed
97ff911751 Update: 2026-05-06 01:38:39 2026-05-06 01:38:39 +03:00
Hamza-Ayed
c63d9944ee Update: 2026-05-05 16:31:41 2026-05-05 16:31:41 +03:00
Hamza-Ayed
fde1ee03d9 fix: PSR-4 compliance — rename core/middleware/services to PascalCase for Linux server compatibility 2026-05-05 16:24:47 +03:00
Hamza-Ayed
50538bc5b9 Update: 2026-05-05 01:48:24 2026-05-05 01:48:24 +03:00
Hamza-Ayed
cfbd9c0009 Update: 2026-05-05 01:44:06 2026-05-05 01:44:06 +03:00
Hamza-Ayed
dcbf8bd04f Update: 2026-05-05 01:31:02 2026-05-05 01:31:02 +03:00
Hamza-Ayed
2f6a08800d Update: 2026-05-05 01:28:39 2026-05-05 01:28:39 +03:00
Hamza-Ayed
fdaffaafbc Update: 2026-05-05 01:23:49 2026-05-05 01:23:49 +03:00
Hamza-Ayed
b2ed480d93 Update: 2026-05-05 01:19:43 2026-05-05 01:19:43 +03:00
Hamza-Ayed
ed525250c2 Update: 2026-05-05 00:12:11 2026-05-05 00:12:11 +03:00
Hamza-Ayed
79c88c47cc Update: 2026-05-05 00:05:06 2026-05-05 00:05:06 +03:00
Hamza-Ayed
ac12106770 Update: 2026-05-05 00:01:17 2026-05-05 00:01:17 +03:00
Hamza-Ayed
5f7018390a final result 4-5-2026 2026-05-04 23:09:00 +03:00
Hamza-Ayed
fbfaae8af4 Update: 2026-05-04 22:57:42 2026-05-04 22:57:43 +03:00
Hamza-Ayed
2585abe2fa Update: 2026-05-04 22:52:19 2026-05-04 22:52:19 +03:00
Hamza-Ayed
97c4e8620c Update: 2026-05-04 22:51:12 2026-05-04 22:51:12 +03:00
Hamza-Ayed
2a474b946c Update: 2026-05-04 21:58:19 2026-05-04 21:58:19 +03:00
Hamza-Ayed
6b940fc4b1 Update: 2026-05-04 21:54:02 2026-05-04 21:54:02 +03:00
Hamza-Ayed
3d21444d1f Update: 2026-05-04 21:46:07 2026-05-04 21:46:07 +03:00
Hamza-Ayed
70446519e0 Update: 2026-05-04 21:43:07 2026-05-04 21:43:07 +03:00
Hamza-Ayed
23189713dc Update: 2026-05-04 21:37:54 2026-05-04 21:37:54 +03:00
Hamza-Ayed
75f969f821 Update: 2026-05-04 21:34:28 2026-05-04 21:34:28 +03:00
Hamza-Ayed
e5f8d8151d Update: 2026-05-04 21:23:43 2026-05-04 21:23:43 +03:00
Hamza-Ayed
ff1a5f8b8c Update: 2026-05-04 21:21:14 2026-05-04 21:21:14 +03:00
Hamza-Ayed
3249a227d6 Update: 2026-05-04 20:12:58 2026-05-04 20:12:58 +03:00
Hamza-Ayed
8d499716ce Update: 2026-05-04 20:10:28 2026-05-04 20:10:28 +03:00
Hamza-Ayed
3ea64d59ce Update: 2026-05-04 20:03:11 2026-05-04 20:03:11 +03:00
Hamza-Ayed
691305340a Update: 2026-05-04 18:05:37 2026-05-04 18:05:37 +03:00
Hamza-Ayed
2d25bee2a6 Update: 2026-05-04 18:00:43 2026-05-04 18:00:43 +03:00
Hamza-Ayed
51ae81a9fa Update: 2026-05-04 17:59:11 2026-05-04 17:59:11 +03:00
Hamza-Ayed
98c4b922be Update: 2026-05-04 17:29:56 2026-05-04 17:29:56 +03:00
Hamza-Ayed
47652b4d95 Update: 2026-05-04 16:06:15 2026-05-04 16:06:15 +03:00
Hamza-Ayed
863dabc069 Update: 2026-05-04 14:40:41 2026-05-04 14:40:41 +03:00
Hamza-Ayed
ebb70e657e Update: 2026-05-04 02:53:16 2026-05-04 02:53:16 +03:00
Hamza-Ayed
02309488ad Update: 2026-05-04 02:29:13 2026-05-04 02:29:13 +03:00
Hamza-Ayed
e704ba127c Update: 2026-05-04 02:24:10 2026-05-04 02:24:10 +03:00
Hamza-Ayed
3e9d380e6d Update: 2026-05-04 02:20:59 2026-05-04 02:20:59 +03:00
Hamza-Ayed
c6040b3b85 Update: 2026-05-04 02:18:52 2026-05-04 02:18:52 +03:00
Hamza-Ayed
3ff2d8d8e1 Update: 2026-05-04 02:14:03 2026-05-04 02:14:03 +03:00
Hamza-Ayed
303205d52d Update: 2026-05-04 02:12:25 2026-05-04 02:12:25 +03:00
Hamza-Ayed
b21951e4c8 Update: 2026-05-04 02:10:24 2026-05-04 02:10:24 +03:00
Hamza-Ayed
ea1d78cb85 Update: 2026-05-04 02:05:03 2026-05-04 02:05:03 +03:00
Hamza-Ayed
ee37a4fa52 Update: 2026-05-04 02:03:26 2026-05-04 02:03:26 +03:00
Hamza-Ayed
2af604df7f Update: 2026-05-04 02:00:51 2026-05-04 02:00:51 +03:00
Hamza-Ayed
5dd8fe46f3 Update: 2026-05-04 01:59:47 2026-05-04 01:59:47 +03:00
Hamza-Ayed
3976a5346b Update: 2026-05-04 01:57:45 2026-05-04 01:57:45 +03:00
Hamza-Ayed
87d6b8b1c0 Update: 2026-05-04 01:55:05 2026-05-04 01:55:05 +03:00
Hamza-Ayed
282f33ca3a Update: 2026-05-04 01:52:13 2026-05-04 01:52:13 +03:00
Hamza-Ayed
08106ac4ea Update: 2026-05-04 01:46:58 2026-05-04 01:46:58 +03:00
Hamza-Ayed
90f2f6f6e3 Update: 2026-05-04 01:33:55 2026-05-04 01:33:55 +03:00
Hamza-Ayed
ad48142492 Update: 2026-05-04 00:50:30 2026-05-04 00:50:30 +03:00
Hamza-Ayed
79308d7f9b Update: 2026-05-04 00:48:53 2026-05-04 00:48:53 +03:00
Hamza-Ayed
5abc22dcd8 Update: 2026-05-04 00:37:13 2026-05-04 00:37:14 +03:00
Hamza-Ayed
e9cea98e95 Update: 2026-05-04 00:29:31 2026-05-04 00:29:31 +03:00
Hamza-Ayed
b4ac1e8775 Update: 2026-05-04 00:27:42 2026-05-04 00:27:42 +03:00
Hamza-Ayed
cd85fcf2bd Update: 2026-05-04 00:23:45 2026-05-04 00:23:45 +03:00
Hamza-Ayed
671db50f16 Update: 2026-05-04 00:13:56 2026-05-04 00:13:56 +03:00
Hamza-Ayed
8357add763 Update: 2026-05-04 00:09:02 2026-05-04 00:09:02 +03:00
Hamza-Ayed
2ac63eef47 Update: 2026-05-04 00:04:41 2026-05-04 00:04:41 +03:00
Hamza-Ayed
c1d31231b4 Update: 2026-05-04 00:01:44 2026-05-04 00:01:44 +03:00
Hamza-Ayed
b6db8da450 Update: 2026-05-03 23:57:27 2026-05-03 23:57:27 +03:00
Hamza-Ayed
bef134ea77 Update: 2026-05-03 23:08:56 2026-05-03 23:08:56 +03:00
Hamza-Ayed
87809ac893 Update: 2026-05-03 22:51:59 2026-05-03 22:51:59 +03:00
Hamza-Ayed
6d2c61497c Update: 2026-05-03 22:38:30 2026-05-03 22:38:30 +03:00
Hamza-Ayed
13bbc29e0e Update: 2026-05-03 22:35:31 2026-05-03 22:35:31 +03:00
Hamza-Ayed
2732229642 Update: 2026-05-03 22:26:56 2026-05-03 22:26:56 +03:00
Hamza-Ayed
ab9625839e Update: 2026-05-03 22:15:40 2026-05-03 22:15:40 +03:00
Hamza-Ayed
089a2b76c0 Update: 2026-05-03 21:58:11 2026-05-03 21:58:11 +03:00
Hamza-Ayed
e1d4917369 Update: 2026-05-03 21:37:02 2026-05-03 21:37:02 +03:00
358 changed files with 42324 additions and 182 deletions

BIN
.DS_Store vendored

Binary file not shown.

2225
PROJECT_DOCUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

181
app/Core/AI.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
namespace App\Core;
use App\Services\InvoiceExtractionService;
/**
* Gemini AI Integration for Invoice Extraction
* Optimized for Jordan UBL 2.1 Compliance
*/
class AI
{
private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/" . AIConfig::MODEL_NAME . ":generateContent";
private static int $maxRetries = 3;
/**
* Extract Data from Invoice Image or PDF (Base64)
*/
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
{
$apiKey = env('GEMINI_API_KEY');
if (!$apiKey) {
error_log('AI Error: GEMINI_API_KEY is missing');
return null;
}
$prompt = AIConfig::getExtractionPrompt();
$payload = [
"contents" => [
[
"parts" => [
["text" => $prompt . " If the image is not an invoice, is blank, or is completely unreadable, return ONLY: {\"error\": \"invalid_invoice\"}. DO NOT guess or invent data."],
[
"inline_data" => [
"mime_type" => $mimeType,
"data" => $base64Data
]
]
]
]
],
"generationConfig" => [
"response_mime_type" => "application/json"
]
];
// Retry with exponential backoff for 503/429 errors
for ($attempt = 1; $attempt <= self::$maxRetries; $attempt++) {
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
if ($attempt < self::$maxRetries) {
$wait = pow(2, $attempt) + rand(1, 3);
echo " Retrying in {$wait}s (cURL error)...\n";
sleep($wait);
continue;
}
return null;
}
if ($httpCode === 200) {
break; // Success
}
// Retry on 503 (overloaded) or 429 (rate limit)
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
$wait = pow(2, $attempt) + rand(1, 3);
echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n";
sleep($wait);
continue;
}
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
return null;
}
if ($httpCode !== 200) {
error_log("AI Error: All retries exhausted. Last code: $httpCode");
return null;
}
$result = json_decode($response, true);
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResponse) return null;
// --- ADDED FOR DEBUGGING ---
@file_put_contents(STORAGE_PATH . '/logs/worker.log', "[" . date('Y-m-d H:i:s') . "] [AI_RAW_RESPONSE]\n" . $textResponse . "\n", FILE_APPEND);
// ---------------------------
$data = json_decode($textResponse, true);
if (isset($data['error']) && $data['error'] === 'invalid_invoice') {
return null;
}
// If the AI returns an array of invoices, extract the first one
if (isset($data['invoices']) && is_array($data['invoices']) && count($data['invoices']) > 0) {
$data = $data['invoices'][0];
}
// Track token usage from Gemini response
$usage = $result['usageMetadata'] ?? [];
if (!empty($usage)) {
self::logTokenUsage($usage);
}
return $data;
}
/**
* Log AI token usage to the database for cost tracking
*/
private static function logTokenUsage(array $usage): void
{
try {
$db = Database::getInstance();
$inputTokens = (int)($usage['promptTokenCount'] ?? 0);
$outputTokens = (int)($usage['candidatesTokenCount'] ?? 0);
$totalTokens = (int)($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens));
// Gemini Flash Lite pricing: $0.075/1M input, $0.30/1M output
$inputCost = ($inputTokens / 1000000) * 0.075;
$outputCost = ($outputTokens / 1000000) * 0.30;
$totalCostUsd = $inputCost + $outputCost;
$totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD
$db->prepare("
INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at)
VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW())
")->execute([
$inputTokens,
$outputTokens,
$totalTokens,
round($totalCostUsd, 8),
round($totalCostJod, 8),
]);
} catch (\Exception $e) {
// Never crash the main flow for logging
error_log("[AI] Token usage log failed: " . $e->getMessage());
}
}
/**
* Get aggregated AI usage stats
*/
public static function getUsageStats(?string $tenantId = null): array
{
try {
$db = Database::getInstance();
$stmt = $db->query("
SELECT
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(total_tokens), 0) as total_tokens,
COALESCE(SUM(cost_usd), 0) as total_cost_usd,
COALESCE(SUM(cost_jod), 0) as total_cost_jod,
COALESCE(AVG(total_tokens), 0) as avg_tokens_per_request,
COALESCE(AVG(cost_jod), 0) as avg_cost_jod_per_request
FROM ai_usage_log
");
return $stmt->fetch() ?: [];
} catch (\Exception $e) {
return [];
}
}
}

22
app/Core/AIConfig.php Normal file
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

@@ -43,4 +43,9 @@ final class Database
return self::$instance;
}
public static function generateUuid(): string
{
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
}
}

View File

@@ -44,20 +44,38 @@ final class Encryption
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
}
$encryptionKey = hash('sha256', $key, true);
$decoded = base64_decode($encryptedData);
// Handle common prefixing issues or trailing whitespace
$encryptedData = trim($encryptedData);
if (str_starts_with($encryptedData, '==')) {
$encryptedData = substr($encryptedData, 2);
}
if ($decoded === false) return false;
$encryptionKey = hash('sha256', $key, true);
$decoded = base64_decode($encryptedData, true);
if ($decoded === false) {
error_log("ENCRYPTION ERROR: Invalid base64 data provided for decryption.");
return false;
}
$ivLength = openssl_cipher_iv_length(self::CIPHER);
$tagLength = 16;
if (strlen($decoded) < $ivLength + $tagLength) return false;
if (strlen($decoded) < $ivLength + $tagLength) {
// This is likely legacy unencrypted data, return false silently
return false;
}
$iv = substr($decoded, 0, $ivLength);
$tag = substr($decoded, $ivLength, $tagLength);
$ciphertext = substr($decoded, $ivLength + $tagLength);
return openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
$result = openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
if ($result === false) {
error_log("ENCRYPTION ERROR: openssl_decrypt failed. Key might be wrong or data corrupted.");
}
return $result;
}
}

213
app/Core/JoFotara.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
namespace App\Core;
/**
* JoFotara (Jordan E-Invoicing) Integration Core
* Handles UBL 2.1 XML Generation, Cryptography, and API Communication
*/
class JoFotara
{
private string $baseUrl = 'https://backend.jofotara.gov.jo/core/invoices/';
/**
* 1. Generate UBL 2.1 XML for an invoice
*/
public function generateXML(array $invoice, array $company): string
{
$issueDate = $invoice['invoice_date'] ?? date('Y-m-d');
$issueTime = date('H:i:s');
$typeCode = $invoice['ubl_type_code'] ?? '388';
$category = $invoice['invoice_category'] ?? 'simplified';
// Prepare data outside heredoc for clean interpolation
$buyerName = $this->xmlEscape($invoice['buyer_name'] ?: 'عميل نقدي');
$buyerId = $invoice['buyer_tin'] ?: $invoice['buyer_national_id'] ?: '000000000';
$payMethod = $invoice['payment_method_code'] ?: '013';
$supplierName = $this->xmlEscape($company['name']);
$supplierAddress = $this->xmlEscape($company['address'] ?? '');
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>urn:www.cen.eu:en16931:2017#compliant#urn:www.josefotara.jo:trns:ubl:3.0</cbc:CustomizationID>
<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
<cbc:ID>{$invoice['invoice_number']}</cbc:ID>
<cbc:IssueDate>{$issueDate}</cbc:IssueDate>
<cbc:IssueTime>{$issueTime}</cbc:IssueTime>
<cbc:InvoiceTypeCode name="{$category}">{$typeCode}</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>JOD</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>JOD</cbc:TaxCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName><cbc:Name>{$supplierName}</cbc:Name></cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>{$supplierAddress}</cbc:StreetName>
<cac:Country><cbc:IdentificationCode>JO</cbc:IdentificationCode></cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>{$company['tax_identification_number']}</cbc:CompanyID>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>{$supplierName}</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyName><cbc:Name>{$buyerName}</cbc:Name></cac:PartyName>
<cac:PartyTaxScheme>
<cbc:CompanyID>{$buyerId}</cbc:CompanyID>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:PartyTaxScheme>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>{$payMethod}</cbc:PaymentMeansCode>
</cac:PaymentMeans>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>16.000</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($invoice['subtotal'])}</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="JOD">{$this->fmt($invoice['discount_total'])}</cbc:AllowanceTotalAmount>
<cbc:PayableAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
{$this->buildInvoiceLines($invoice['items'])}
</Invoice>
XML;
return $xml;
}
private function buildInvoiceLines(array $items): string
{
$result = '';
foreach ($items as $item) {
$taxAmount = round($item['line_total'] * $item['tax_rate'], 3);
$taxCategory = $item['tax_rate'] > 0 ? 'S' : 'Z';
$result .= <<<XML
<cac:InvoiceLine>
<cbc:ID>{$item['line_number']}</cbc:ID>
<cbc:InvoicedQuantity unitCode="PCE">{$this->fmt($item['quantity'])}</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:LineExtensionAmount>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>{$taxCategory}</cbc:ID>
<cbc:Percent>{$this->fmt($item['tax_rate'] * 100)}</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description>{$this->xmlEscape($item['description'])}</cbc:Description>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="JOD">{$this->fmt($item['unit_price'])}</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
XML;
}
return $result;
}
private function fmt(float $val): string { return number_format($val, 3, '.', ''); }
private function xmlEscape(string $str): string { return htmlspecialchars($str, ENT_XML1, 'UTF-8'); }
/**
* 2. Generate Base64 TLV QR Code (Local Fallback)
*/
public function generateQRCode(array $invoiceData): string
{
$sellerName = $invoiceData['supplier_name'] ?? '';
$taxNumber = $invoiceData['supplier_tin'] ?? '';
$timestamp = date('Y-m-d\TH:i:s\Z', strtotime($invoiceData['invoice_date'] ?? 'now'));
$total = number_format($invoiceData['grand_total'] ?? 0, 3, '.', '');
$vat = number_format($invoiceData['tax_amount'] ?? 0, 3, '.', '');
$tlv = $this->toTLV(1, $sellerName) .
$this->toTLV(2, $taxNumber) .
$this->toTLV(3, $timestamp) .
$this->toTLV(4, $total) .
$this->toTLV(5, $vat);
return base64_encode($tlv);
}
private function toTLV(int $tag, string $value): string
{
return chr($tag) . chr(strlen($value)) . $value;
}
/**
* 3. Submit Invoice to JoFotara API
*/
public function submitInvoice(string $xmlContent, string $clientId, string $secretKey): array
{
// For production, we must encode XML in Base64 and wrap in JSON
$payload = json_encode([
'invoice' => base64_encode($xmlContent)
]);
$ch = curl_init($this->baseUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"ClientId: $clientId",
"SecretKey: $secretKey"
],
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($response, true) ?? [];
$decoded['_http_code'] = $httpCode;
if ($httpCode === 200) {
return [
'success' => true,
'uuid' => $decoded['invoiceUUID'] ?? $decoded['uuid'] ?? 'mock-' . uniqid(),
'qrCode' => $decoded['qrCode'] ?? $decoded['QRCode'] ?? null,
'raw' => $decoded
];
}
return [
'success' => false,
'error' => $decoded['errorMessage'] ?? 'API Connection Failed',
'raw' => $decoded
];
}
}

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

49
app/Core/Validator.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
/**
* Simple Data Validator
*/
declare(strict_types=1);
namespace App\Core;
final class Validator
{
public static function validate(array $data, array $rules): array
{
$errors = [];
foreach ($rules as $field => $rule) {
$value = $data[$field] ?? null;
if (str_contains($rule, 'required') && (empty($value) && $value !== '0')) {
$errors[$field] = "The {$field} field is required.";
continue; // Skip further rules if required field is missing
}
if (str_contains($rule, 'email') && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "The {$field} must be a valid email address.";
}
// Password strength: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit
if (str_contains($rule, 'strong_password') && !empty($value)) {
if (strlen($value) < 8) {
$errors[$field] = 'كلمة المرور يجب أن تكون 8 أحرف على الأقل.';
} elseif (!preg_match('/[A-Z]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف كبير واحد على الأقل.';
} elseif (!preg_match('/[a-z]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف صغير واحد على الأقل.';
} elseif (!preg_match('/[0-9]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على رقم واحد على الأقل.';
}
}
// Generic min length: min:8
if (preg_match('/min:(\d+)/', $rule, $m) && !empty($value)) {
if (mb_strlen($value) < (int)$m[1]) {
$errors[$field] = "The {$field} must be at least {$m[1]} characters.";
}
}
}
return $errors;
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* Simple Authentication Middleware
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\JWT;
final class AuthMiddleware
{
public static function check(): array
{
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (!str_starts_with($authHeader, 'Bearer ')) {
json_error('Unauthorized: Missing or invalid token', 401);
}
$token = substr($authHeader, 7);
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short');
json_error('Server configuration error', 500);
}
$decoded = JWT::decode($token, $secret);
if (!$decoded) {
// Check if it's specifically expired if your JWT class supports it,
// otherwise just send the standard 401 with a code.
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'انتهت صلاحية الجلسة',
'code' => 'TOKEN_EXPIRED',
'redirect'=> '/login.php'
]);
exit;
}
return $decoded;
}
}

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

@@ -0,0 +1,62 @@
<?php
/**
* HMAC Request Signature Middleware
*
* Verifies that incoming requests are signed with a shared secret,
* preventing replay attacks and ensuring request integrity.
*
* Client must send:
* X-Timestamp: Unix timestamp (seconds)
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Security;
final class HmacMiddleware
{
/**
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
*/
public static function verify(int $maxAgeSeconds = 300): void
{
$headers = getallheaders();
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
// 1. Ensure both headers are present
if (empty($signature) || empty($timestamp)) {
json_error('Missing HMAC signature or timestamp', 401);
}
// 2. Validate timestamp is numeric
if (!ctype_digit((string)$timestamp)) {
json_error('Invalid timestamp format', 401);
}
// 3. Replay attack prevention — reject stale requests
$age = abs(time() - (int)$timestamp);
if ($age > $maxAgeSeconds) {
json_error('Request expired. Check your system clock.', 401);
}
// 4. Build the expected signature
$body = file_get_contents('php://input');
$payload = $timestamp . '.' . $body;
$secret = env('HMAC_SECRET_KEY');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
json_error('Server configuration error', 500);
}
// 5. Verify using constant-time comparison (prevents timing attacks)
if (!Security::verifySignature($payload, $signature, $secret)) {
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
json_error('Invalid request signature', 401);
}
}
}

View File

@@ -0,0 +1,295 @@
<?php
/**
* Quota Enforcement Middleware
*
* Checks tenant subscription limits before allowing resource creation.
* Automatically resets monthly counters when the billing period rolls over.
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Database;
use App\Core\Cache;
final class QuotaMiddleware
{
/**
* Check if the tenant can upload more invoices this month.
* Automatically resets the counter if the billing period has ended.
*
* @return array The current subscription data (for UI display)
*/
public static function checkInvoiceQuota(string $tenantId): array
{
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
if ($sub === false || $sub === null) {
$db = Database::getInstance();
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub) {
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
}
}
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
}
// Check subscription status
if ($sub['status'] === 'cancelled') {
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
}
if ($sub['status'] === 'past_due') {
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
}
// Auto-reset period counter if billing period has ended
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
$newStart = date('Y-m-d H:i:s');
$cycle = $sub['billing_cycle'] ?? 'annual';
$interval = ($cycle === 'monthly') ? '+1 month' : '+1 year';
$newEnd = date('Y-m-d H:i:s', strtotime($interval));
$resetStmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = 0,
current_period_start = ?,
current_period_end = ?,
updated_at = NOW()
WHERE tenant_id = ?
");
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
$sub['invoices_used_this_month'] = 0;
$sub['current_period_start'] = $newStart;
$sub['current_period_end'] = $newEnd;
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
}
// Check invoice quota
$used = (int)$sub['invoices_used_this_month'];
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
if ($used >= $limit) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'period_end' => $sub['current_period_end'],
]);
}
return $sub;
}
/**
* Increment the monthly invoice counter after a successful upload.
*/
public static function incrementInvoiceUsage(string $tenantId): void
{
$db = Database::getInstance();
$stmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = invoices_used_this_month + 1,
updated_at = NOW()
WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
// Invalidate cache
Cache::delete("quota_sub_{$tenantId}");
}
/**
* Check if the tenant can add more companies.
*/
public static function checkCompanyQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active companies
$countStmt = $db->prepare("
SELECT COUNT(*) FROM companies
WHERE tenant_id = ? AND (deleted_at IS NULL)
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$limit = (int)$sub['max_companies'];
if ($currentCount >= $limit) {
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
'quota_type' => 'companies',
'used' => $currentCount,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Check if the tenant can add more users.
*/
public static function checkUserQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active users in this tenant
$countStmt = $db->prepare("
SELECT COUNT(*) FROM users
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$maxUsers = (int)($sub['max_users'] ?? 999);
if ($currentCount >= $maxUsers) {
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
'quota_type' => 'users',
'used' => $currentCount,
'limit' => $maxUsers,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Get usage summary for a tenant (for dashboard display).
*/
public static function getUsageSummary(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
return [
'has_subscription' => false,
'plan' => 'none',
];
}
// Count companies
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$compStmt->execute([$tenantId]);
$companiesUsed = (int)$compStmt->fetchColumn();
// Count users
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
$userStmt->execute([$tenantId]);
$usersUsed = (int)$userStmt->fetchColumn();
$invoicesUsed = (int)$sub['invoices_used_this_month'];
$invoicesLimit = (int)$sub['max_invoices_per_month'];
$companiesLimit = (int)$sub['max_companies'];
$usersLimit = (int)($sub['max_users'] ?? 999);
// Check for pending payment request
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$pendingPayment = $stmt->fetch();
return [
'has_subscription' => true,
'plan_id' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
'plan_price' => (float)($sub['plan_price'] ?? 0),
'status' => $sub['status'],
'ai_features' => (bool)($sub['ai_features'] ?? false),
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
'pending_payment' => $pendingPayment ? [
'id' => $pendingPayment['id'],
'plan_id' => $pendingPayment['plan_id'],
'reference' => $pendingPayment['internal_reference']
] : null,
'invoices' => [
'used' => $invoicesUsed,
'limit' => $invoicesLimit,
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
],
'companies' => [
'used' => $companiesUsed,
'limit' => $companiesLimit,
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
],
'users' => [
'used' => $usersUsed,
'limit' => $usersLimit,
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
],
'period_start' => $sub['current_period_start'],
'period_end' => $sub['current_period_end'],
'trial_ends_at' => $sub['trial_ends_at'],
'days_remaining' => !empty($sub['current_period_end'])
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
: null,
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* Rate Limiting Middleware (File-based, Race-Condition Safe)
*/
declare(strict_types=1);
namespace App\Middleware;
final class RateLimitMiddleware
{
/**
* File-based rate limiter with file-lock to prevent race conditions.
* For multi-server deployments, replace with Redis.
*/
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = 'rl:' . md5($ip);
// 1. Try Redis first
$redis = \App\Core\Cache::getInstance();
if ($redis) {
try {
$count = $redis->get($key);
if ($count && (int)$count >= $maxRequests) {
header('Retry-After: ' . $timeWindow);
json_error('Too Many Requests. Please slow down.', 429);
}
if (!$count) {
$redis->setex($key, $timeWindow, 1);
} else {
$redis->incr($key);
}
return; // Success with Redis
} catch (\Exception $e) {
// Fallback to file-based if Redis fails
}
}
// 2. Fallback: File-based rate limiter (original logic)
$cacheDir = STORAGE_PATH . '/cache';
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
$fp = fopen($cacheFile, 'c+');
if ($fp === false) return;
try {
flock($fp, LOCK_EX);
$now = time();
$content = stream_get_contents($fp);
$requests = [];
if (!empty($content)) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$requests = array_values(array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)));
}
}
if (count($requests) >= $maxRequests) {
flock($fp, LOCK_UN);
fclose($fp);
header('Retry-After: ' . $timeWindow);
json_error('Too Many Requests. Please slow down.', 429);
}
$requests[] = $now;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($requests));
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
}

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

@@ -0,0 +1,173 @@
<?php
namespace App\Services;
class InvoiceExtractionService
{
public function buildExtractionPrompt(): string
{
return <<<'PROMPT'
أنت نظام متخصص في استخلاص بيانات الفواتير التجارية الأردنية. مهمتك الوحيدة: استخراج البيانات بدقة تامة وتصنيف الضرائب بشكل صحيح.
════════════════════════════════════════
## قواعد اللغة والأرقام (إلزامية):
════════════════════════════════════════
- إذا كانت الفاتورة بالعربية: أبقِ أسماء السلع والعناوين بالعربية دون ترجمة
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية دون ترجمة
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
- المبالغ دائماً بـ 3 أرقام عشرية (مثال: 15.000 وليس 15 أو 15.00)
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
════════════════════════════════════════
## التحقق الرياضي والفواتير الشاملة للضريبة (إلزامي):
════════════════════════════════════════
- معظم فواتير التجزئة والسوبرماركت (POS) في الأردن تكون "شاملة للضريبة" (Tax Inclusive).
- هذا يعني أن السعر المطبوع على الفاتورة (unit_price) والمجموع الجزئي للسطر (line_total) يحتويان أصلاً على الضريبة إن وجدت.
- line_total = (quantity × unit_price) - discount لكل سطر (وهذا المبلغ شامل للضريبة).
- subtotal = مجموع كل line_total
- grand_total = subtotal - discount_total (يجب أن يتطابق تماماً مع المبلغ الكلي المطلوب من العميل في الفاتورة).
- tax_amount = مجموع الضرائب المحسوبة عكسياً من line_total (أو كما هي مذكورة صراحةً في أسفل الفاتورة). إياك أن تضيف tax_amount فوق subtotal إذا كانت الفاتورة شاملة للضريبة.
- إذا كانت الفاتورة من النوع النادر غير الشامل للضريبة (Tax Exclusive): grand_total = subtotal - discount_total + tax_amount
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: يجب أن تعطي الأولوية القصوى لتطابق `grand_total` مع الرقم المطبوع الذي تم دفعه فعلياً، وسجِّل أي ملاحظات في validation_warnings.
════════════════════════════════════════
## جدول الضرائب الأردنية (مرجعك الإلزامي):
════════════════════════════════════════
### نسبة 0.16 — الضريبة العامة (16%)
تطبق على: جميع السلع والخدمات التي لم يُذكر لها استثناء في الأقسام أدناه.
### نسبة 0.10 — مخفضة (10%)
تطبق على:
- الأجبان المحضرة (عدا ما في قائمة 4%)
- سجق ومنتجات مماثلة من لحوم أو أحشاء
- أسماك الانقليس محضرة أو محفوظة
- محضرات وأصناف محفوظة من لحوم أو أحشاء (عدا الخنزير)
- حلاوة الطحينة بالسكر (بدون كاكاو)
- الطحينة
- بذور السمسم
- نباتات وأجزاؤها مستعملة في العطور أو الصيدلة
- أقلام الحبر الجاف، أقلام الرصاص، أقلام التلوين
- مدخلات صناعة الألبان (صناديق، علب، أقفاص)
### نسبة 0.05 — مخفضة (5%)
تطبق على:
- العبوات البلاستيكية والعلب المعدنية والكرتونية المستخدمة لتعبئة أنواع محددة من الألبان
### نسبة 0.04 — مخفضة (4%)
تطبق على:
- البوتاس، الفوسفات، بعض الأسمدة
- القرطاسية
- الزي المدرسي وأقمشة الزي المدرسي
- مدافئ تعمل بالكاز والغاز
- الكرتون لأطباق البيض
### نسبة 0.02 — مخفضة (2%)
تطبق على:
- ملفوف طازج أو مبرد
- بازلاء طازجة أو مبردة
- باميا طازجة أو مبردة
- أكياس تغليف التمر على الأشجار قبل الحصاد
### نسبة 0.00 — صفري (0%) — فئة: "Z" — يُسمح بخصم ضريبة المدخلات
تطبق على:
- اللحوم (عدا ما في قائمة 10%)
- الأسماك (عدا الانقليس)
- المحضرات الخاصة لتغذية الأطفال والمعوقين والمحضرات الطبية
- أغطية بلاستيك للزراعة (الملش الزراعي)
- لوازم شبكات الري (أنابيب، فواصل، أكواع)
- صناديق وأقفاص خشبية لتعبئة المنتجات الزراعية
- بيض الطيور الطازج لصناعة اللقاحات البيطرية
- بصيلات ودرنات وجذور في طور البيات
- هياكل البيوت الزراعية من حديد أو صلب
- آلات وأدوات البستنة ومحادل الملاعب
- نباتات وجذور الهندباء
- زيوت النفط الخام والغازات البترولية (عدا زيوت التشحيم)
- الأدوية واللقاحات البيطرية
- أسمدة NPK، اليوريا، الأمونياك
### معفاة كلياً — فئة: "E" — لا يُسمح بخصم ضريبة المدخلات
تطبق على:
- دقيق الحنطة
- عدس وحمص يابس والبقوليات
- زيت الزيتون غير المعدل كيماوياً
- سكر مكرر (عدا سكر القصب)
- الشاي الأسود (عبوات ≤ 3 كغ)
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (مثل حليب نيدو)
- الألبان (اللبن الرائب، الشنينة، لبن حمودة، الخ) والأجبان البيضاء العادية.
- بيض المائدة
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
- أجهزة الهواتف الذكية
- الطاقة الكهربائية
- النقود الورقية والمعدنية
- حافلات نقل 10 أشخاص أو أكثر
- سيارات عمرها 5 سنوات فأكثر
- السيارات الكهربائية والهجينة
### ضريبة خاصة — فئة: "O"
تطبق على: الإسمنت، التبغ، المشروبات الكحولية، السيارات الجديدة، المحروقات، زيوت التشحيم
════════════════════════════════════════
## قواعد تصنيف الضريبة لكل سطر:
════════════════════════════════════════
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة. المواد الغذائية الأساسية في السوبرماركت (ألبان، أجبان، حليب، خبز) غالباً معفاة (0% أو 4%). لا تفرض 16% إلا على الكماليات (منظفات، حلويات، عصائر مصنعة، الخ).
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية للسلع غير الغذائية والخدمات.
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
4. tax_category: استخدم "standard" للخاضعة (16% أو مخفضة)، "zero_rated" للصفري، "exempt" للمعفاة، "special" للخاصة
════════════════════════════════════════
## تصنيف طريقة الدفع:
════════════════════════════════════════
- "013" = نقداً (cash, كاش, نقد)
- "010" = بطاقة ائتمانية أو مدى (credit card, debit card, بطاقة)
- "001" = تحويل بنكي (bank transfer, حوالة بنكية, شيك)
- إذا لم تُذكر → افتراضي "013"
════════════════════════════════════════
## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
════════════════════════════════════════
{
"invoices": [
{
"invoice_number": "string | null",
"invoice_date": "YYYY-MM-DD | null",
"invoice_type": "cash | credit",
"payment_method_code": "013 | 010 | 001",
"ubl_type_code": "388",
"supplier": {
"name": "string | null",
"tin": "string | null",
"address": "string | null"
},
"buyer": {
"name": "string | null",
"tin": "string | null",
"national_id": "string | null"
},
"lines": [
{
"line_number": 1,
"description": "string",
"quantity": 1.000,
"unit_price": 0.000,
"discount": 0.000,
"tax_rate": 0.16,
"tax_category": "standard | zero_rated | exempt | special",
"tax_exempt_reason": "string | null",
"line_total": 0.000
}
],
"subtotal": 0.000,
"discount_total": 0.000,
"tax_amount": 0.000,
"grand_total": 0.000,
"currency_code": "JOD",
"math_verified": true,
"validation_warnings": [],
"ai_confidence": 0.95
}
]
}
PROMPT;
}
}

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

@@ -6,13 +6,29 @@
declare(strict_types=1);
// 1. Basic Constants
define('ROOT_PATH', dirname(__DIR__, 2));
define('ROOT_PATH', realpath(dirname(__DIR__, 2)));
define('APP_PATH', ROOT_PATH . '/app');
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, 0755, true);
}
}
// 3. Error Reporting (Secure for production)
if (env('APP_DEBUG', 'false') === 'true') {
@@ -47,12 +63,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
// 5. Security Headers
header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: DENY");
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=()");
// 6. Intelligent Autoloader (Case-Insensitive for directories)
// CSP: Allow self + known CDNs (Tailwind, Alpine, Google Fonts)
$csp = "default-src 'self'; "
. "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; "
. "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
. "font-src 'self' https://fonts.gstatic.com; "
. "img-src 'self' data:; "
. "connect-src 'self';";
header("Content-Security-Policy: $csp");
// 6. Request body size limit (2MB for JSON, file uploads handled separately)
if (isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 2 * 1024 * 1024) {
if (empty($_FILES)) { // Don't block file uploads
http_response_code(413);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Request body too large'], JSON_UNESCAPED_UNICODE);
exit;
}
}
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$base_dir = APP_PATH . '/';
@@ -64,7 +100,7 @@ spl_autoload_register(function ($class) {
$parts = explode('\\', $relative_class);
$filename = array_pop($parts) . '.php';
$dir = strtolower(implode('/', $parts));
$dir = implode('/', $parts); // No strtolower — preserves PascalCase on Linux
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;

80
app/config/plans.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* Subscription Plans Configuration (Fallback)
*
* This is used as a fallback when the database subscription_plans
* table is not available. The database is the source of truth.
*/
return [
'free' => [
'id' => 'free',
'name_ar' => 'التجربة المجانية',
'name_en' => 'Free Trial',
'max_companies' => 1,
'max_invoices_month' => 15,
'max_users' => 1,
'price_jod' => 0.00,
'price_monthly_jod' => 0.00,
'price_annual_jod' => 0.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'gray',
'description_ar' => 'للتجربة الأولية — شركة واحدة و15 فاتورة شهرياً',
'features' => [
'استخراج الفواتير بالذكاء الاصطناعي',
'الربط المباشر مع جوفوترة',
'شركة واحدة فقط',
'15 فاتورة شهرياً',
'مستخدم واحد',
],
],
'basic' => [
'id' => 'basic',
'name_ar' => 'الباقة الأساسية',
'name_en' => 'Basic Plan',
'max_companies' => 3,
'max_invoices_month' => 500,
'max_users' => 2,
'price_jod' => 15.00, // Default legacy price
'price_monthly_jod' => 15.00,
'price_annual_jod' => 120.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'blue',
'description_ar' => 'للمحاسبين المستقلين والشركات الصغيرة — 3 شركات',
'features' => [
'استخراج الفواتير بالذكاء الاصطناعي',
'الربط المباشر مع جوفوترة',
'حتى 3 شركات (بدلاً من واحدة)',
'500 فاتورة شهرياً (سخية جداً)',
'مستخدمين اثنين',
'دعم فني عبر الواتساب',
],
],
'pro' => [
'id' => 'pro',
'name_ar' => 'الباقة الاحترافية',
'name_en' => 'Pro Plan',
'max_companies' => 9999,
'max_invoices_month' => 3000,
'max_users' => 5,
'price_jod' => 35.00, // Default legacy price
'price_monthly_jod' => 35.00,
'price_annual_jod' => 290.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'gold',
'is_popular' => true,
'description_ar' => 'للمكاتب الكبيرة والموزعين — حجم عمل ضخم',
'features' => [
'استخراج الفواتير بالذكاء الاصطناعي',
'الربط المباشر مع جوفوترة',
'عدد شركات غير محدود',
'3,000 فاتورة شهرياً',
'5 مستخدمين',
'API كامل لتطبيق الهاتف',
'مدير حساب مخصص',
],
],
];

View File

@@ -1,25 +0,0 @@
<?php
/**
* Simple Data Validator
*/
declare(strict_types=1);
namespace App\Core;
final class Validator
{
public static function validate(array $data, array $rules): array
{
$errors = [];
foreach ($rules as $field => $rule) {
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
$errors[$field] = "The {$field} field is required.";
}
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "The {$field} must be a valid email address.";
}
}
return $errors;
}
}

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

@@ -31,7 +31,17 @@ final class AuthMiddleware
$decoded = JWT::decode($token, $secret);
if (!$decoded) {
json_error('Unauthorized: Invalid or expired token', 401);
// Check if it's specifically expired if your JWT class supports it,
// otherwise just send the standard 401 with a code.
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'انتهت صلاحية الجلسة',
'code' => 'TOKEN_EXPIRED',
'redirect'=> '/login.php'
]);
exit;
}
return $decoded;

View File

@@ -0,0 +1,295 @@
<?php
/**
* Quota Enforcement Middleware
*
* Checks tenant subscription limits before allowing resource creation.
* Automatically resets monthly counters when the billing period rolls over.
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Database;
use App\Core\Cache;
final class QuotaMiddleware
{
/**
* Check if the tenant can upload more invoices this month.
* Automatically resets the counter if the billing period has ended.
*
* @return array The current subscription data (for UI display)
*/
public static function checkInvoiceQuota(string $tenantId): array
{
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
if ($sub === false || $sub === null) {
$db = Database::getInstance();
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub) {
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
}
}
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
}
// Check subscription status
if ($sub['status'] === 'cancelled') {
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
}
if ($sub['status'] === 'past_due') {
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
}
// Auto-reset period counter if billing period has ended
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
$newStart = date('Y-m-d H:i:s');
$cycle = $sub['billing_cycle'] ?? 'annual';
$interval = ($cycle === 'monthly') ? '+1 month' : '+1 year';
$newEnd = date('Y-m-d H:i:s', strtotime($interval));
$resetStmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = 0,
current_period_start = ?,
current_period_end = ?,
updated_at = NOW()
WHERE tenant_id = ?
");
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
$sub['invoices_used_this_month'] = 0;
$sub['current_period_start'] = $newStart;
$sub['current_period_end'] = $newEnd;
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
}
// Check invoice quota
$used = (int)$sub['invoices_used_this_month'];
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
if ($used >= $limit) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'period_end' => $sub['current_period_end'],
]);
}
return $sub;
}
/**
* Increment the monthly invoice counter after a successful upload.
*/
public static function incrementInvoiceUsage(string $tenantId): void
{
$db = Database::getInstance();
$stmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = invoices_used_this_month + 1,
updated_at = NOW()
WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
// Invalidate cache
Cache::delete("quota_sub_{$tenantId}");
}
/**
* Check if the tenant can add more companies.
*/
public static function checkCompanyQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active companies
$countStmt = $db->prepare("
SELECT COUNT(*) FROM companies
WHERE tenant_id = ? AND (deleted_at IS NULL)
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$limit = (int)$sub['max_companies'];
if ($currentCount >= $limit) {
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
'quota_type' => 'companies',
'used' => $currentCount,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Check if the tenant can add more users.
*/
public static function checkUserQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active users in this tenant
$countStmt = $db->prepare("
SELECT COUNT(*) FROM users
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$maxUsers = (int)($sub['max_users'] ?? 999);
if ($currentCount >= $maxUsers) {
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
'quota_type' => 'users',
'used' => $currentCount,
'limit' => $maxUsers,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Get usage summary for a tenant (for dashboard display).
*/
public static function getUsageSummary(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
return [
'has_subscription' => false,
'plan' => 'none',
];
}
// Count companies
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$compStmt->execute([$tenantId]);
$companiesUsed = (int)$compStmt->fetchColumn();
// Count users
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
$userStmt->execute([$tenantId]);
$usersUsed = (int)$userStmt->fetchColumn();
$invoicesUsed = (int)$sub['invoices_used_this_month'];
$invoicesLimit = (int)$sub['max_invoices_per_month'];
$companiesLimit = (int)$sub['max_companies'];
$usersLimit = (int)($sub['max_users'] ?? 999);
// Check for pending payment request
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$pendingPayment = $stmt->fetch();
return [
'has_subscription' => true,
'plan_id' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
'plan_price' => (float)($sub['plan_price'] ?? 0),
'status' => $sub['status'],
'ai_features' => (bool)($sub['ai_features'] ?? false),
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
'pending_payment' => $pendingPayment ? [
'id' => $pendingPayment['id'],
'plan_id' => $pendingPayment['plan_id'],
'reference' => $pendingPayment['internal_reference']
] : null,
'invoices' => [
'used' => $invoicesUsed,
'limit' => $invoicesLimit,
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
],
'companies' => [
'used' => $companiesUsed,
'limit' => $companiesLimit,
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
],
'users' => [
'used' => $usersUsed,
'limit' => $usersLimit,
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
],
'period_start' => $sub['current_period_start'],
'period_end' => $sub['current_period_end'],
'trial_ends_at' => $sub['trial_ends_at'],
'days_remaining' => !empty($sub['current_period_end'])
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
: null,
];
}
}

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

@@ -28,42 +28,156 @@ if ($errors) {
$email = $data['email'];
$password = $data['password'];
// 2. DB Check
// 2. DB Check (Using hash for lookup since email is encrypted)
$db = Database::getInstance();
$stmt = $db->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$emailHash = hash('sha256', strtolower($email));
$stmt = $db->prepare("SELECT * FROM users WHERE email_hash = ? LIMIT 1");
$stmt->execute([$emailHash]);
$user = $stmt->fetch();
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'],
'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'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']]);
// 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' => $user['name'],
'email' => $user['email']
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
'role' => $user['role'],
'tenant_id' => $user['tenant_id']
]
], 'تم تسجيل الدخول بنجاح');

View File

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

View File

@@ -0,0 +1,182 @@
<?php
/**
* Mobile OTP Verify Endpoint
* POST /v1/auth/mobile/verify-otp
*
* Verifies OTP, registers device, and returns JWT + device secret for HMAC.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\JWT;
use App\Core\Validator;
use App\Core\Security;
use App\Middleware\RateLimitMiddleware;
// Rate limit: 10 verify attempts per minute per IP
RateLimitMiddleware::check(10, 60);
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'phone' => 'required',
'otp' => 'required',
]);
if ($errors) {
json_error('رقم الهاتف ورمز التحقق مطلوبان', 422, $errors);
}
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$phoneHash = hash('sha256', $phone);
$deviceId = $data['device_id'] ?? '';
$deviceName = $data['device_name'] ?? 'Unknown Device';
$platform = $data['platform'] ?? 'android';
$appVersion = $data['app_version'] ?? '1.0.0';
$pushToken = $data['push_token'] ?? null;
if (empty($deviceId)) {
json_error('معرّف الجهاز مطلوب', 422);
}
// 2. Load OTP from cache
$cacheFile = STORAGE_PATH . '/cache/otp/otp_' . $phoneHash . '.json';
if (!file_exists($cacheFile)) {
json_error('رمز التحقق غير صالح أو منتهي الصلاحية', 401);
}
$fp = fopen($cacheFile, 'r+');
if (!$fp) {
json_error('خطأ في النظام', 500);
}
flock($fp, LOCK_EX);
$content = stream_get_contents($fp);
$otpData = json_decode($content, true);
if (!$otpData || $otpData['expires_at'] < time()) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
json_error('رمز التحقق منتهي الصلاحية. اطلب رمزاً جديداً.', 401);
}
// Check attempts
if ($otpData['attempts'] >= $otpData['max_attempts']) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
json_error('تجاوزت عدد المحاولات المسموحة. اطلب رمزاً جديداً.', 429);
}
// Verify OTP
if (!password_verify($data['otp'], $otpData['hash'])) {
$otpData['attempts']++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($otpData));
flock($fp, LOCK_UN);
fclose($fp);
$remaining = $otpData['max_attempts'] - $otpData['attempts'];
json_error("رمز التحقق غير صحيح. المحاولات المتبقية: {$remaining}", 401);
}
// OTP is valid — clean up
flock($fp, LOCK_UN);
fclose($fp);
@unlink($cacheFile);
// 3. Fetch user
$db = Database::getInstance();
$userId = $otpData['user_id'];
$stmt = $db->prepare("SELECT id, tenant_id, name, email, role, is_active FROM users WHERE id = ? LIMIT 1");
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || !$user['is_active']) {
json_error('الحساب غير موجود أو معطّل', 403);
}
// 4. Generate device secret for HMAC
$deviceSecret = hash('sha256', $userId . $deviceId . bin2hex(random_bytes(16)));
// 5. Register/Update device
$stmt = $db->prepare("
INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, push_token, device_secret, is_trusted, last_seen_at)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, TRUE, NOW())
ON DUPLICATE KEY UPDATE
device_name = VALUES(device_name),
platform = VALUES(platform),
app_version = VALUES(app_version),
push_token = VALUES(push_token),
device_secret = VALUES(device_secret),
is_trusted = TRUE,
last_seen_at = NOW(),
updated_at = NOW()
");
$stmt->execute([
$userId,
$deviceId,
$deviceName,
$platform,
$appVersion,
$pushToken,
password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed
]);
// 6. Generate JWT (30 days for mobile)
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'device_id' => $deviceId,
'source' => 'mobile',
'exp' => time() + (30 * 24 * 3600), // 30 days
];
$token = JWT::encode($payload, $secret);
// 7. Generate refresh token
$refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
$stmt->execute([$refreshTokenHash, $userId]);
// 8. Decrypt name for response
$userName = $user['name'];
try {
$decrypted = \App\Core\Encryption::decrypt($user['name']);
if ($decrypted !== false) $userName = $decrypted;
} catch (\Exception $e) {
// Keep encrypted name
}
json_success([
'access_token' => $token,
'refresh_token' => $refreshToken,
'device_secret' => $deviceSecret, // Client stores this securely for HMAC
'user' => [
'id' => $user['id'],
'name' => $userName,
'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
'role' => $user['role'],
'tenant_id' => $user['tenant_id'],
],
], 'تم التحقق بنجاح. مرحباً بك في مُصادَق!');

View File

@@ -1,21 +1,23 @@
<?php
/**
* Auth Refresh Endpoint
* Refresh Token Endpoint (Secure Cookie Based)
*/
use App\Core\Database;
use App\Core\JWT;
use Firebase\JWT\JWT;
$data = input();
$refreshToken = $data['refresh_token'] ?? null;
// 1. Get Refresh Token from HttpOnly Cookie
$refreshToken = $_COOKIE['refresh_token'] ?? null;
if (!$refreshToken) {
json_error('Refresh token is required', 400);
json_error('Refresh token is required', 401);
}
$db = Database::getInstance();
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
// 2. Verify in DB
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch();
@@ -23,25 +25,21 @@ if (!$user) {
json_error('Invalid refresh token', 401);
}
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
// 3. Generate New Access Token
$secret = $_ENV['JWT_SECRET'] ?? null;
if (!$secret) {
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'role' => $user['role'],
'exp' => time() + (15 * 60)
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'], // Now including tenant_id
'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
];
$newToken = JWT::encode($payload, $secret);
$newRefreshToken = bin2hex(random_bytes(32));
$newRefreshTokenHash = hash('sha256', $newRefreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
$stmt->execute([$newRefreshTokenHash, $user['id']]);
$token = JWT::encode($payload, $secret, 'HS256');
json_success([
'access_token' => $newToken,
'refresh_token' => $newRefreshToken
], 'تم تجديد الجلسة بنجاح');
'access_token' => $token
]);

View File

@@ -0,0 +1,60 @@
<?php
/**
* Register/Update Device Endpoint
* POST /v1/auth/mobile/register-device
*
* Updates push token and device info for an already-authenticated device.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
$decoded = AuthMiddleware::check();
$userId = $decoded['user_id'];
$deviceId = $decoded['device_id'] ?? null;
if (!$deviceId) {
json_error('هذا الـ endpoint مخصص لتطبيق الهاتف فقط', 403);
}
$data = Security::sanitize(input());
$db = Database::getInstance();
$updateFields = [];
$params = [];
if (isset($data['push_token'])) {
$updateFields[] = 'push_token = ?';
$params[] = $data['push_token'];
}
if (isset($data['app_version'])) {
$updateFields[] = 'app_version = ?';
$params[] = $data['app_version'];
}
if (isset($data['device_name'])) {
$updateFields[] = 'device_name = ?';
$params[] = $data['device_name'];
}
// Always update last_seen
$updateFields[] = 'last_seen_at = NOW()';
if (empty($updateFields)) {
json_success(null, 'لا يوجد بيانات للتحديث');
exit;
}
$sql = "UPDATE user_devices SET " . implode(', ', $updateFields) . " WHERE user_id = ? AND device_fingerprint = ?";
$params[] = $userId;
$params[] = $deviceId;
$stmt = $db->prepare($sql);
$stmt->execute($params);
json_success(null, 'تم تحديث بيانات الجهاز');

View File

@@ -0,0 +1,83 @@
<?php
/**
* Create Batch Endpoint
* POST /v1/batches/create
*
* Creates a new invoice batch for the mobile scanner.
* Returns batch_id that the mobile app uses to upload images.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
use App\Core\Validator;
use App\Middleware\QuotaMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize(input());
// 1. Validate
$errors = Validator::validate($data, [
'company_id' => 'required',
]);
if ($errors) {
json_error('رقم الشركة مطلوب', 422, $errors);
}
$companyId = $data['company_id'];
$source = $data['source'] ?? 'mobile_scan';
$expectedImages = (int)($data['expected_images'] ?? 0);
// 2. Permission check
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId]);
$company = $stmt->fetch();
if (!$company) {
json_error('الشركة غير موجودة', 404);
}
// Check tenant match if not super_admin
if ($decoded['role'] !== 'super_admin' && $company['tenant_id'] !== $tenantId) {
json_error('الوصول مرفوض لهذه الشركة', 403);
}
// Use the actual tenant of the company
$targetTenantId = $company['tenant_id'];
// 3. Check quota (preview — don't increment yet)
if ($decoded['role'] !== 'super_admin') {
try {
QuotaMiddleware::checkInvoiceQuota($targetTenantId);
} catch (\Exception $e) {
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
}
}
// 4. Generate batch ID
$batchId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
// 5. Create batch record
$stmt = $db->prepare("
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
VALUES (?, ?, ?, ?, ?, ?, 'uploading')
");
$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $expectedImages, $source]);
// 6. Create upload directory
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
json_success([
'batch_id' => $batchId,
'upload_url' => 'v1/batches/upload-image',
], 'تم إنشاء الدفعة بنجاح. ابدأ برفع الصور.');

View File

@@ -0,0 +1,142 @@
<?php
/**
* Finalize Batch Endpoint
* POST /v1/batches/finalize
*
* Marks a batch as ready for processing.
* Sends instant response to mobile app, then processes in background via fastcgi_finish_request.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
use App\Services\InvoiceProcessor;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize(input());
$batchId = $data['batch_id'] ?? null;
if (!$batchId) {
json_error('معرّف الدفعة مطلوب', 422);
}
$db = Database::getInstance();
// 1. Verify batch
$stmt = $db->prepare("
SELECT id, tenant_id, status, total_images
FROM invoice_batches
WHERE id = ? AND uploaded_by = ?
");
$stmt->execute([$batchId, $userId]);
$batch = $stmt->fetch();
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة', 404);
}
if ($batch['status'] !== 'uploading') {
json_error('تم إنهاء هذه الدفعة مسبقاً', 400);
}
if ($batch['total_images'] == 0) {
json_error('لا يمكن إنهاء دفعة فارغة', 400);
}
// 2. Mark as processing
$stmt = $db->prepare("
UPDATE invoice_batches
SET status = 'processing', updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$batchId]);
// 3. Send response IMMEDIATELY to mobile app
// We manually build the response instead of using json_success() because it calls exit()
$responsePayload = json_encode([
'success' => true,
'data' => [
'batch_id' => $batchId,
'status' => 'processing',
'total_images' => $batch['total_images']
],
'message' => 'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية',
'timestamp' => date('c')
], JSON_UNESCAPED_UNICODE);
// Set headers
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($responsePayload));
http_response_code(200);
// Flush ALL output buffers to send response to client NOW
echo $responsePayload;
// Flush PHP output buffers
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
// Log the API call for app.log (mimicking json_response behavior)
$logEntry = sprintf(
"API %s %s | 200 | OK | %s",
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
$_SERVER['REQUEST_URI'] ?? '',
'تم إنهاء الدفعة بنجاح وبدء المعالجة الفورية'
);
error_log($logEntry);
@file_put_contents(
STORAGE_PATH . '/logs/app.log',
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
FILE_APPEND
);
// 4. Tell PHP-FPM: "The client response is done. But keep this PHP process alive."
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// 5. Now process in the background (client has already received the response)
ignore_user_abort(true);
set_time_limit(300); // 5 minutes max
$bgLog = function(string $msg) {
@file_put_contents(
STORAGE_PATH . '/logs/worker.log',
"[" . date('Y-m-d H:i:s') . "] [finalize-bg] " . $msg . "\n",
FILE_APPEND
);
};
$bgLog("Background processing started for batch: $batchId");
try {
$queueStmt = $db->prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending' ORDER BY created_at ASC");
$queueStmt->execute([$batchId]);
$items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN);
$bgLog("Found " . count($items) . " pending item(s) for batch $batchId");
foreach ($items as $queueId) {
$bgLog("Processing queue item: $queueId");
try {
$success = InvoiceProcessor::processQueueItem((int)$queueId);
$bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED"));
} catch (\Throwable $e) {
$bgLog("Queue item $queueId EXCEPTION: " . $e->getMessage());
}
}
$bgLog("Background processing finished for batch: $batchId");
} catch (\Throwable $e) {
$bgLog("FATAL ERROR in background processing: " . $e->getMessage());
}
exit;

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

@@ -0,0 +1,54 @@
<?php
/**
* Batch Status Endpoint
* GET /v1/batches/status
*
* Returns the processing status of a batch and its items.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\Security;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$data = Security::sanitize($_GET);
$batchId = $data['batch_id'] ?? null;
if (!$batchId) {
json_error('معرّف الدفعة مطلوب', 422);
}
$db = Database::getInstance();
// 1. Get batch info
$stmt = $db->prepare("
SELECT id, tenant_id, status, total_images, processed_images, failed_images, created_at, completed_at
FROM invoice_batches
WHERE id = ?
");
$stmt->execute([$batchId]);
$batch = $stmt->fetch();
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة', 404);
}
// 2. Get items
$stmt = $db->prepare("
SELECT id, invoice_id, image_order, status, error_message, created_at, processed_at
FROM invoice_processing_queue
WHERE batch_id = ?
ORDER BY image_order ASC
");
$stmt->execute([$batchId]);
$items = $stmt->fetchAll();
json_success([
'batch' => $batch,
'items' => $items
], 'تم جلب حالة الدفعة');

View File

@@ -0,0 +1,100 @@
<?php
/**
* Upload Image to Batch
* POST /v1/batches/upload-image
*
* Uploads a single image to an existing batch.
* Supports multipart/form-data with 'image' file and 'batch_id'.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
// 1. Validate request
$batchId = $_POST['batch_id'] ?? null;
$imageOrder = (int)($_POST['image_order'] ?? 0);
if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$uploadError = $_FILES['image']['error'] ?? 'No file';
json_error("معرّف الدفعة وصورة الفاتورة مطلوبان (كود: {$uploadError})", 422);
}
// 2. Verify batch belongs to this user and tenant
$db = Database::getInstance();
$stmt = $db->prepare("
SELECT id, tenant_id, company_id, status, total_images
FROM invoice_batches
WHERE id = ? AND uploaded_by = ?
");
$stmt->execute([$batchId, $userId]);
$batch = $stmt->fetch();
if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $tenantId)) {
json_error('الدفعة غير موجودة أو ليس لديك صلاحية', 404);
}
// Override tenantId with the actual batch's tenantId
$tenantId = $batch['tenant_id'];
if ($batch['status'] !== 'uploading') {
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
}
// 3. Validate file type
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
$mimeType = $_FILES['image']['type'];
if (!in_array($mimeType, $allowedTypes)) {
json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422);
}
// 4. Validate file size (max 10MB)
$maxSize = 10 * 1024 * 1024;
if ($_FILES['image']['size'] > $maxSize) {
json_error('حجم الصورة أكبر من 10 ميغابايت', 422);
}
// 5. Save file
$companyId = $batch['company_id'];
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$extension = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION) ?: 'jpg';
$fileName = sprintf('img_%03d_%s.%s', $imageOrder, bin2hex(random_bytes(4)), $extension);
$targetPath = $uploadDir . '/' . $fileName;
if (!move_uploaded_file($_FILES['image']['tmp_name'], $targetPath)) {
json_error('فشل في حفظ الصورة', 500);
}
// 6. Add to processing queue
$stmt = $db->prepare("
INSERT INTO invoice_processing_queue (batch_id, tenant_id, company_id, image_path, image_order, status)
VALUES (?, ?, ?, ?, ?, 'pending')
");
$stmt->execute([$batchId, $tenantId, $companyId, $targetPath, $imageOrder]);
// 7. Update batch image count
$stmt = $db->prepare("
UPDATE invoice_batches
SET total_images = total_images + 1, updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$batchId]);
// Count uploaded so far
$stmt = $db->prepare("SELECT COUNT(*) FROM invoice_processing_queue WHERE batch_id = ?");
$stmt->execute([$batchId]);
$uploadedCount = (int)$stmt->fetchColumn();
json_success([
'uploaded' => $uploadedCount,
'file_name' => $fileName,
], "تم رفع الصورة بنجاح ({$uploadedCount} صور في الدفعة)");

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

@@ -0,0 +1,65 @@
<?php
/**
* Link Company to JoFotara API
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\JoFotara;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
if (!in_array($decoded['role'], ['super_admin', 'admin'])) {
json_error('Unauthorized to modify JoFotara settings', 403);
}
$db = Database::getInstance();
$data = json_decode(file_get_contents('php://input'), true);
$companyId = $data['id'] ?? null;
$clientId = $data['client_id'] ?? null;
$secretKey = $data['secret_key'] ?? null;
$sequence = $data['income_source_sequence'] ?? null;
if (!$companyId || !$clientId || !$secretKey) {
json_error('Company ID, Client ID, and Secret Key are required', 422);
}
$tenantId = $decoded['tenant_id'];
try {
// 2. Validate Company Ownership
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ?");
$stmt->execute([$companyId, $tenantId]);
if (!$stmt->fetch()) json_error('Access denied', 403);
// 3. Test Connection (Optional but recommended)
$jofotara = new JoFotara();
// Here you would typically call a health check endpoint if JoFotara provides one,
// or just assume the credentials are correct for now.
// 4. Update Company with Encrypted Credentials
$stmtUpdate = $db->prepare("
UPDATE companies
SET
jofotara_client_id_encrypted = ?,
jofotara_secret_key_encrypted = ?,
jofotara_income_source_sequence = ?,
updated_at = NOW()
WHERE id = ?
");
$stmtUpdate->execute([
Encryption::encrypt($clientId),
Encryption::encrypt($secretKey),
$sequence,
$companyId
]);
json_success(null, 'تم ربط الشركة بنظام جوفوترة بنجاح');
} catch (\Exception $e) {
error_log("JoFotara Connection Error: " . $e->getMessage());
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Create Company Endpoint (Synchronized Schema)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$data = input();
// 1. Validation
$errors = Validator::validate($data, [
'name' => 'required',
'tax_identification_number' => 'required'
]);
if ($errors) {
json_error('Validation Failed', 422, $errors);
}
$db = Database::getInstance();
try {
$db->beginTransaction();
// 2. Encrypt sensitive fields
$encryptedName = Encryption::encrypt($data['name']);
$encryptedNameEn = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null;
// Encrypt JoFotara keys if provided
$jofotaraClientId = !empty($data['jofotara_client_id']) ? Encryption::encrypt($data['jofotara_client_id']) : null;
$jofotaraSecretKey = !empty($data['jofotara_secret_key']) ? Encryption::encrypt($data['jofotara_secret_key']) : null;
// 3. Save to Database
$stmt = $db->prepare("
INSERT INTO companies (
id, tenant_id, name, name_en, tax_identification_number, commercial_registration_number,
city, address, contact_email, contact_phone,
jofotara_client_id_encrypted, jofotara_secret_key_encrypted,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
// Determine tenant_id: Super Admin chooses, Admin uses own
$tenantId = null;
if ($decoded['role'] === 'super_admin') {
if (empty($data['tenant_id'])) {
json_error('يجب اختيار المكتب المحاسبي', 422);
}
$tenantId = $data['tenant_id'];
} else {
$tenantId = $decoded['tenant_id'];
}
// --- QUOTA CHECK ---
\App\Middleware\QuotaMiddleware::checkCompanyQuota($tenantId);
// -------------------
$stmt->execute([
\App\Core\Database::generateUuid(),
$tenantId,
$encryptedName,
$encryptedNameEn,
$data['tax_identification_number'],
$data['commercial_registration_number'] ?? null,
$data['city'] ?? null,
$data['address'] ?? null,
$data['contact_email'] ?? null,
$data['contact_phone'] ?? null,
$jofotaraClientId,
$jofotaraSecretKey,
date('Y-m-d H:i:s')
]);
$db->commit();
AuditLogger::log('company.created', 'company', null, null, [
'name' => $data['name'],
'tin' => $data['tax_identification_number'],
], $decoded);
json_success(null, 'تم إنشاء الشركة بنجاح');
} catch (\Exception $e) {
$db->rollBack();
error_log("[companies/create] Error: " . $e->getMessage());
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Delete Company Endpoint (Soft Delete)
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
use App\Middleware\CompanyAccessMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$companyId = input('id');
if (!$companyId) {
json_error('Company ID is required', 422);
}
// Authorization
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('Unauthorized', 403);
}
// Fetch company to check tenant if admin
$stmt = $db->prepare("SELECT tenant_id FROM companies WHERE id = ?");
$stmt->execute([$companyId]);
$company = $stmt->fetch();
if (!$company) {
json_error('الشركة غير موجودة', 404);
}
// Verify tenant access (admin can only delete from their tenant)
CompanyAccessMiddleware::check($companyId, $decoded);
// Soft Delete
$stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?");
$stmt->execute([$companyId]);
AuditLogger::log('company.deleted', 'company', $companyId, null, null, $decoded);
json_success(null, 'تم حذف الشركة بنجاح');

View File

@@ -0,0 +1,68 @@
<?php
/**
* List Companies Endpoint (Synchronized Schema)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
try {
// 1. Super Admin sees ALL companies
if ($decoded['role'] === 'super_admin') {
$stmt = $db->prepare("
SELECT c.*, t.name as tenant_name,
(SELECT COUNT(*) FROM invoices WHERE company_id = c.id AND deleted_at IS NULL) as invoices_count,
(SELECT SUM(grand_total) FROM invoices WHERE company_id = c.id AND deleted_at IS NULL) as total_amount
FROM companies c
LEFT JOIN tenants t ON c.tenant_id = t.id
WHERE c.deleted_at IS NULL ORDER BY c.created_at DESC
");
$stmt->execute();
$companies = $stmt->fetchAll();
}
// 2. Tenant Users (Admin, Accountant, Employee) see all companies in their tenant
else {
$stmt = $db->prepare("
SELECT *,
(SELECT COUNT(*) FROM invoices WHERE company_id = companies.id AND deleted_at IS NULL) as invoices_count,
(SELECT SUM(grand_total) FROM invoices WHERE company_id = companies.id AND deleted_at IS NULL) as total_amount
FROM companies
WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
");
$stmt->execute([$decoded['tenant_id']]);
$companies = $stmt->fetchAll();
}
// 3. Decrypt fields
$dec = function($val) {
if (empty($val)) return '';
$result = \App\Core\Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
foreach ($companies as &$company) {
$company['name'] = $dec($company['name']);
if (!empty($company['name_en'])) {
$company['name_en'] = $dec($company['name_en']);
}
if (isset($company['tenant_name'])) {
$company['tenant_name'] = $dec($company['tenant_name']);
}
// Redact JoFotara secrets
$company['jofotara_client_id_encrypted'] = !empty($company['jofotara_client_id_encrypted']);
unset($company['jofotara_secret_key_encrypted']);
unset($company['certificate_password_encrypted']);
}
json_success($companies);
} catch (\Exception $e) {
safe_error($e, 'companies/index');
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* Company Monthly Stats & JoFotara Status
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$companyId = $_GET['company_id'] ?? $_GET['id'] ?? null;
if (!$companyId) json_error('Company ID is required', 422);
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
try {
// 2. Permission Check
if ($role === 'super_admin') {
$stmt = $db->prepare("SELECT id, name, tax_identification_number, is_active,
(jofotara_client_id_encrypted IS NOT NULL) as is_jofotara_connected,
jofotara_income_source_sequence
FROM companies WHERE id = ?");
$stmt->execute([$companyId]);
} else {
$stmt = $db->prepare("SELECT id, name, tax_identification_number, is_active,
(jofotara_client_id_encrypted IS NOT NULL) as is_jofotara_connected,
jofotara_income_source_sequence
FROM companies WHERE id = ? AND tenant_id = ?");
$stmt->execute([$companyId, $tenantId]);
}
$company = $stmt->fetch();
if (!$company) json_error('Company not found', 404);
// Decrypt company name
$dec = Encryption::decrypt($company['name']);
$company['name'] = ($dec !== false && $dec !== '') ? $dec : $company['name'];
// 3. Monthly Invoice Stats (including tax)
$stmtStats = $db->prepare("
SELECT
DATE_FORMAT(invoice_date, '%Y-%m') as month,
COUNT(*) as total_invoices,
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count,
COALESCE(SUM(grand_total), 0) as total_amount,
COALESCE(SUM(tax_amount), 0) as total_tax
FROM invoices
WHERE company_id = ? AND deleted_at IS NULL
GROUP BY month
ORDER BY month DESC
LIMIT 12
");
$stmtStats->execute([$companyId]);
$monthly = $stmtStats->fetchAll();
// 4. Lifetime Totals
$stmtTotals = $db->prepare("
SELECT
COUNT(*) as total_invoices,
COALESCE(SUM(grand_total), 0) as total_amount,
COALESCE(SUM(tax_amount), 0) as total_tax,
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count
FROM invoices
WHERE company_id = ? AND deleted_at IS NULL
");
$stmtTotals->execute([$companyId]);
$totals = $stmtTotals->fetch();
json_success([
'company' => $company,
'monthly' => $monthly,
'totals' => $totals
]);
} catch (\Exception $e) {
error_log("Company Stats Error: " . $e->getMessage());
json_error('Server error', 500);
}

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

@@ -0,0 +1,81 @@
<?php
/**
* Dashboard Stats Endpoint (Role-Based & Tenant-Aware)
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'] ?? null;
$companyId = $decoded['company_id'] ?? null;
$role = $decoded['role'];
try {
$stats = [
'role' => $role,
'invoices' => [
'total' => 0,
'pending' => 0,
'approved' => 0
]
];
// 2. Fetch Invoice Stats
if ($role === 'super_admin') {
$where = "WHERE 1=1";
$params = [];
} elseif ($role === 'accountant' || $role === 'viewer') {
$where = "WHERE tenant_id = ? AND company_id = ?";
$params = [$tenantId, $companyId];
} else {
// admin
$where = "WHERE tenant_id = ?";
$params = [$tenantId];
}
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where");
$stmt->execute($params);
$stats['invoices']['total'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'");
$stmt->execute($params);
$stats['invoices']['pending'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
$stmt->execute($params);
$stats['invoices']['approved'] = (int)$stmt->fetchColumn();
// 3. Role-Specific Extra Stats
if ($role === 'super_admin') {
$stats['tenants'] = (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn();
$stats['total_users'] = (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn();
} elseif ($role === 'admin') {
$stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$stats['companies'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$stats['users'] = (int)$stmt->fetchColumn();
// Get Subscription Quota
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if ($sub) {
$stats['subscription'] = [
'limit' => (int)$sub['max_invoices_per_month'],
'used' => (int)$sub['invoices_used_this_month']
];
}
}
} catch (\Exception $e) {
// Return default zeroed stats on error
}
json_success($stats);

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

@@ -0,0 +1,143 @@
<?php
/**
* Approve Invoice & Submit to JoFotara
*/
use App\Core\Database;
use App\Core\JoFotara;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
use App\Middleware\CompanyAccessMiddleware;
// Only admin, accountant, and super_admin can approve. Viewers cannot.
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
$db = Database::getInstance();
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? null;
if (!$id) {
json_error('Invoice ID is required', 422);
}
try {
$db->beginTransaction();
// 1. Fetch Invoice & Company
$stmt = $db->prepare("
SELECT i.*, c.name as company_name, c.tax_identification_number as company_tin,
c.address as company_address, c.jofotara_client_id_encrypted, c.jofotara_secret_key_encrypted
FROM invoices i
JOIN companies c ON i.company_id = c.id
WHERE i.id = ? FOR UPDATE
");
$stmt->execute([$id]);
$invoice = $stmt->fetch();
if (!$invoice) json_error('Invoice not found', 404);
if ($invoice['status'] === 'approved') json_error('Already approved', 400);
// 2. Fetch Line Items
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?");
$stmtLines->execute([$id]);
$invoice['items'] = $stmtLines->fetchAll();
// 3. Decrypt Company Keys for JoFotara
$clientId = \App\Core\Encryption::decrypt($invoice['jofotara_client_id_encrypted'] ?? '');
$secretKey = \App\Core\Encryption::decrypt($invoice['jofotara_secret_key_encrypted'] ?? '');
$jofotara = new JoFotara();
$apiResponse = ['success' => false];
$xmlContent = null;
// 4. Try JoFotara Submission if credentials exist
if ($clientId && $secretKey) {
$companyData = [
'name' => $invoice['company_name'],
'tax_identification_number' => $invoice['company_tin'],
'address' => $invoice['company_address']
];
// Decrypt Buyer Info for XML
$invoice['buyer_name'] = \App\Core\Encryption::decrypt($invoice['buyer_name'] ?? '') ?: ($invoice['buyer_name'] ?? '');
$invoice['buyer_tin'] = \App\Core\Encryption::decrypt($invoice['buyer_tin'] ?? '') ?: ($invoice['buyer_tin'] ?? '');
$xmlContent = $jofotara->generateXML($invoice, $companyData);
$apiResponse = $jofotara->submitInvoice($xmlContent, $clientId, $secretKey);
}
// 5. Fallback: Generate Local QR if API failed or no credentials
$qrCode = $apiResponse['qrCode'] ?? $jofotara->generateQRCode([
'supplier_name' => $invoice['company_name'],
'supplier_tin' => $invoice['company_tin'],
'invoice_date' => $invoice['invoice_date'],
'grand_total' => $invoice['grand_total'],
'tax_amount' => $invoice['tax_amount']
]);
// 6. Record Submission (Audit Log)
$submissionId = \App\Core\Database::generateUuid();
$stmtSub = $db->prepare("
INSERT INTO jofotara_submissions
(id, invoice_id, company_id, tenant_id, xml_payload, xml_hash,
jofotara_uuid, qr_code_raw, response_code, response_body, status, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$status = $apiResponse['success'] ? 'accepted' : 'error';
$stmtSub->execute([
$submissionId,
$id,
$invoice['company_id'],
$invoice['tenant_id'],
$xmlContent,
$xmlContent ? hash('sha256', $xmlContent) : null,
$apiResponse['uuid'] ?? null,
$qrCode,
$apiResponse['_http_code'] ?? '0',
json_encode($apiResponse['raw'] ?? ['info' => 'Local approval / No credentials']),
$status
]);
// 7. Update Invoice
$updateStmt = $db->prepare("
UPDATE invoices SET status = 'approved', jofotara_uuid = ?, qr_code = ?, updated_at = NOW() WHERE id = ?
");
$updateStmt->execute([$apiResponse['uuid'] ?? null, $qrCode, $id]);
$db->commit();
json_success([
'message' => $apiResponse['success'] ? 'تم الاعتماد والإرسال إلى جوفوترة بنجاح' : 'تم الاعتماد محلياً (نظام جوفوترة غير متصل)',
'uuid' => $apiResponse['uuid'] ?? null,
'qr_code' => $qrCode,
'is_api_success' => $apiResponse['success']
]);
AuditLogger::log('invoice.approved', 'invoice', $id, [
'old_status' => $invoice['status'],
], [
'new_status' => 'approved',
'jofotara_uuid' => $apiResponse['uuid'] ?? null,
'api_success' => $apiResponse['success'],
], $decoded);
// Smart Notifications
\App\Services\SmartNotifications::invoiceApproved(
$invoice['tenant_id'], $invoice['uploaded_by'] ?? $decoded['user_id'],
$id, $invoice['invoice_number'] ?? $id
);
\App\Services\SmartNotifications::checkQuotaWarning($invoice['tenant_id']);
// Gamification
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'invoice_approved');
if ($apiResponse['success'] ?? false) {
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'jofotara_submitted');
}
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("JoFotara Approve Error: " . $e->getMessage());
safe_error($e, 'invoices/approve');
}

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,44 @@
<?php
/**
* Official JoFotara XML Download
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
// 2. Validate Request
$id = $_GET['id'] ?? null;
if (!$id) json_error('Invoice ID is required', 422);
$tenantId = $decoded['tenant_id'];
try {
// 3. Fetch accepted submission for this invoice
$stmt = $db->prepare("
SELECT js.xml_payload, js.jofotara_uuid
FROM jofotara_submissions js
JOIN invoices i ON js.invoice_id = i.id
WHERE i.id = ? AND i.tenant_id = ? AND js.status = 'accepted'
ORDER BY js.created_at DESC LIMIT 1
");
$stmt->execute([$id, $tenantId]);
$row = $stmt->fetch();
if (!$row || empty($row['xml_payload'])) {
json_error('لا يوجد XML رسمي متاح لهذه الفاتورة', 404);
}
// 4. Send headers for download
header('Content-Type: application/xml; charset=utf-8');
header('Content-Disposition: attachment; filename="invoice_' . ($row['jofotara_uuid'] ?: $id) . '.xml"');
echo $row['xml_payload'];
exit;
} catch (\Exception $e) {
error_log("XML Download Error: " . $e->getMessage());
json_error('خطأ في تحميل الملف', 500);
}

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

@@ -0,0 +1,69 @@
<?php
/**
* Secure File Proxy for Invoices
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// Helper to output error as an image for debugging
function outputErrorImage($message) {
header('Content-Type: image/png');
$im = imagecreatetruecolor(400, 100);
$bg = imagecolorallocate($im, 20, 20, 20);
$tc = imagecolorallocate($im, 255, 50, 50);
imagefilledrectangle($im, 0, 0, 400, 100, $bg);
imagestring($im, 5, 10, 40, $message, $tc);
imagepng($im);
imagedestroy($im);
exit;
}
// Extract token from header OR query string using helper
$token = input('token');
if (!$token) {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
}
if (!$token) outputErrorImage('Forbidden: No token');
$decoded = \App\Core\JWT::decode($token, env('JWT_SECRET', ''));
if (!$decoded) outputErrorImage('Forbidden: Invalid token');
$db = Database::getInstance();
$id = input('id');
if (!$id) outputErrorImage('Forbidden: No ID');
$stmt = $db->prepare("SELECT tenant_id, original_file_path FROM invoices WHERE id = ?");
$stmt->execute([$id]);
$invoice = $stmt->fetch();
if (!$invoice) outputErrorImage('Error: Invoice not found');
// Authorization
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
outputErrorImage('Error: Unauthorized');
}
$filePath = $invoice['original_file_path'];
if (!file_exists($filePath)) {
outputErrorImage('Error: File missing on disk');
}
if (!is_readable($filePath)) {
outputErrorImage('Error: Permission denied');
}
$mime = mime_content_type($filePath);
if (!$mime) $mime = 'application/octet-stream';
header("Content-Type: $mime");
header("Content-Length: " . filesize($filePath));
header("Cache-Control: public, max-age=3600");
readfile($filePath);
exit;

View File

@@ -0,0 +1,109 @@
<?php
/**
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$role = $decoded['role'];
try {
$pagination = paginate_params(25, 100);
// 2. Build WHERE clause based on Role
$where = '';
$params = [];
if ($role === 'super_admin') {
$where = '1=1';
} elseif ($role === 'admin') {
$where = 'i.tenant_id = ?';
$params = [$tenantId];
} else {
// Accountant/Viewer: Filter by assigned companies
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
$stmtUser->execute([$userId]);
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
if (empty($assignedCompanyIds)) {
json_paginated([], 0, $pagination);
}
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
$where = "i.company_id IN ($placeholders)";
$params = $assignedCompanyIds;
}
// Optional filters from query string
$companyFilter = $_GET['company_id'] ?? null;
$statusFilter = $_GET['status'] ?? null;
$searchFilter = $_GET['search'] ?? null;
if ($companyFilter) {
$where .= ' AND i.company_id = ?';
$params[] = $companyFilter;
}
if ($statusFilter) {
$where .= ' AND i.status = ?';
$params[] = $statusFilter;
}
if ($searchFilter) {
$where .= ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ?)';
$params[] = "%$searchFilter%";
$params[] = "%$searchFilter%";
}
// 3. Count total
$countStmt = $db->prepare("SELECT COUNT(*) FROM invoices i WHERE $where");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
// 4. Fetch page
$joinTenant = ($role === 'super_admin') ? 'LEFT JOIN tenants t ON i.tenant_id = t.id' : '';
$selectTenant = ($role === 'super_admin') ? ', t.name as tenant_name' : '';
$stmt = $db->prepare("
SELECT i.*{$selectTenant}, c.name as company_name
FROM invoices i
LEFT JOIN companies c ON i.company_id = c.id
{$joinTenant}
WHERE {$where}
ORDER BY i.created_at DESC
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
");
$stmt->execute($params);
$invoices = $stmt->fetchAll();
// 5. Decrypt sensitive fields
$dec = function($val) {
if (empty($val)) return '';
$result = Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
foreach ($invoices as &$inv) {
$inv['supplier_name'] = $dec($inv['supplier_name']);
$inv['supplier_tin'] = $dec($inv['supplier_tin']);
$inv['buyer_name'] = $dec($inv['buyer_name']);
if (!empty($inv['company_name'])) {
$inv['company_name'] = $dec($inv['company_name']);
}
if (!empty($inv['tenant_name'])) {
$inv['tenant_name'] = $dec($inv['tenant_name']);
}
}
json_paginated($invoices, $total, $pagination);
} catch (\Exception $e) {
safe_error($e, 'invoices/index');
}

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

@@ -0,0 +1,257 @@
<?php
/**
* Invoice Upload Endpoint (Multi-Tenant & Role-Aware)
*/
// تفعيل إظهار الأخطاء برمجياً لهذه الصفحة فقط لضمان عدم وجود فشل صامت
ini_set('display_errors', 0); // اجعلها 1 مؤقتاً إذا استمرت المشكلة لمعرفة الخطأ من السيرفر
error_reporting(E_ALL);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
use App\Core\AI;
use App\Core\Encryption;
use App\Middleware\QuotaMiddleware;
try {
// 1. Auth Check
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$allowedRoles = ['super_admin', 'admin', 'accountant', 'employee'];
if (!in_array($decoded['role'], $allowedRoles)) {
json_error('غير مصرح لك برفع الفواتير', 403);
exit;
}
// --- QUOTA CHECK (skip for super_admin ONLY) ---
if ($decoded['role'] !== 'super_admin') {
QuotaMiddleware::checkInvoiceQuota($tenantId);
}
// -------------------
$db = Database::getInstance();
// 2. Validate Request
// استخدام $_POST للتعامل الآمن مع multipart/form-data
$companyId = $_POST['company_id'] ?? null;
if (!$companyId && function_exists('input')) {
$data = input();
$companyId = $data['company_id'] ?? null;
}
if (!$companyId || !isset($_FILES['invoice']) || $_FILES['invoice']['error'] !== UPLOAD_ERR_OK) {
$uploadError = $_FILES['invoice']['error'] ?? 'No File';
json_error('رقم الشركة وملف الفاتورة مطلوبان، أو حدث خطأ أثناء الرفع (كود الخطأ: ' . $uploadError . ')', 422);
exit;
}
// 3. Permission Check
if ($decoded['role'] === 'super_admin') {
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId]);
} else {
$stmt = $db->prepare("SELECT id, tenant_id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
}
$company = $stmt->fetch();
if (!$company) {
json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403);
exit;
}
// لضمان حفظ الفاتورة في المكتب الصحيح إذا كان المرفوع سوبر أدمن
if ($decoded['role'] === 'super_admin') {
$tenantId = $company['tenant_id'];
}
// 4. Handle File Upload
$tenantDir = STORAGE_PATH . '/invoices/' . $tenantId;
$companyDir = $tenantDir . '/' . $companyId;
$dateFolder = date('Y-m-d');
$uploadDir = $companyDir . '/' . $dateFolder . '/';
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
error_log('Failed to create storage directory: ' . $dir);
json_error('فشل في تجهيز مساحة التخزين', 500);
exit;
}
chmod($dir, 0755);
}
}
$extension = pathinfo($_FILES['invoice']['name'], PATHINFO_EXTENSION);
$fileName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $extension;
$targetFile = $uploadDir . $fileName;
if (!move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) {
json_error('فشل في نقل الملف المرفوع إلى مسار التخزين', 500);
exit;
}
// 5. Run AI Extraction
$mimeType = $_FILES['invoice']['type'];
$fileContent = file_get_contents($targetFile);
$base64Data = base64_encode($fileContent);
$extracted = AI::extractInvoiceData($base64Data, $mimeType);
if (!$extracted) {
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$stmt = $db->prepare("
INSERT INTO invoices (
id, tenant_id, company_id, uploaded_by, original_file_path, status, created_at
) VALUES (
?, ?, ?, ?, ?, 'uploaded', NOW()
)
");
$stmt->execute([$invoiceId, $tenantId, $companyId, $userId, $targetFile]);
json_success(['id' => $invoiceId], 'تم رفع الفاتورة ولكن فشل استخراج البيانات تلقائياً');
exit; // إيقاف التنفيذ إلزامي هنا لمنع الانهيار
}
// 6. Save Extracted Data
$db->beginTransaction();
$extractedInvoices = $extracted['invoices'] ?? [$extracted];
$savedIds = [];
foreach ($extractedInvoices as $inv) {
$supplierTin = $inv['supplier']['tin'] ?? '';
$invoiceNum = $inv['invoice_number'] ?? '';
$invoiceDate = $inv['invoice_date'] ?? '';
$invoiceHash = null;
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
$rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate;
$invoiceHash = hash('sha256', strtolower($rawHashString));
$checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL");
$checkStmt->execute([$companyId, $invoiceHash]);
if ($checkStmt->fetch()) {
continue; // Skip duplicates in multi-page files
}
}
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
$subtotal = is_numeric($inv['subtotal'] ?? null) ? $inv['subtotal'] : 0;
$tax = is_numeric($inv['tax_amount'] ?? null) ? $inv['tax_amount'] : 0;
$disc = is_numeric($inv['discount_total'] ?? null) ? $inv['discount_total'] : 0;
$total = is_numeric($inv['grand_total'] ?? null) ? $inv['grand_total'] : 0;
$stmt = $db->prepare("
INSERT INTO invoices (
id, tenant_id, company_id, uploaded_by, original_file_path, status,
invoice_number, invoice_date, invoice_type, invoice_category,
supplier_tin, supplier_name, supplier_address,
buyer_tin, buyer_name, buyer_national_id,
subtotal, tax_amount, discount_total, grand_total, currency_code,
invoice_hash, validation_warnings,
created_at
) VALUES (
:id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted',
:num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid,
:sub, :tax, :disc, :total, :cur,
:hash, :warnings,
NOW()
)
");
$stmt->execute([
'id' => $invoiceId,
'tenant_id' => $tenantId,
'company_id' => $companyId,
'uploaded_by' => $userId,
'path' => $targetFile,
'num' => !empty($invoiceNum) ? $invoiceNum : null,
'date' => $validDate,
'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash',
'cat' => !empty($inv['invoice_category']) ? $inv['invoice_category'] : 'simplified',
's_tin' => Encryption::encrypt($supplierTin),
's_name' => Encryption::encrypt($inv['supplier']['name'] ?? ''),
's_addr' => Encryption::encrypt($inv['supplier']['address'] ?? ''),
'b_tin' => Encryption::encrypt($inv['buyer']['tin'] ?? ''),
'b_name' => Encryption::encrypt($inv['buyer']['name'] ?? ''),
'b_nid' => Encryption::encrypt($inv['buyer']['national_id'] ?? ''),
'sub' => $subtotal,
'tax' => $tax,
'disc' => $disc,
'total' => $total,
'cur' => !empty($inv['currency_code']) ? $inv['currency_code'] : 'JOD',
'hash' => $invoiceHash,
'warnings' => !empty($inv['validation_warnings']) ? json_encode($inv['validation_warnings']) : null
]);
// Save Line Items
if (!empty($inv['lines']) && is_array($inv['lines'])) {
$lineStmt = $db->prepare("
INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
foreach ($inv['lines'] as $index => $item) {
$lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$lineStmt->execute([
$lineId,
$invoiceId,
$item['line_number'] ?? ($index + 1),
$item['description'] ?? 'بدون وصف',
is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1,
is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0,
is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16,
is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0
]);
}
}
$savedIds[] = $invoiceId;
if ($decoded['role'] !== 'super_admin') {
QuotaMiddleware::incrementInvoiceUsage($tenantId);
}
}
$db->commit();
if (empty($savedIds)) {
json_error('لم يتم حفظ أي فواتير جديدة من هذا الملف (قد تكون مكررة)', 409);
exit;
}
// --- NOTIFICATIONS & GAMIFICATION (for first invoice only for simplicity) ---
\App\Services\SmartNotifications::checkQuotaWarning($tenantId);
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
// -----------------------
$response = [
'ids' => $savedIds,
'message' => 'تم استخراج وحفظ ' . count($savedIds) . ' فواتير من الملف بنجاح'
];
// Backward compatibility for Flutter (expecting a single 'id')
if (count($savedIds) === 1) {
$response['id'] = $savedIds[0];
}
json_success($response);
exit;
} catch (\PDOException $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
exit;
} catch (\Throwable $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
exit;
}

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

@@ -0,0 +1,110 @@
<?php
/**
* Invoice View Endpoint (Decrypted & JoFotara Aware)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$id = $_GET['id'] ?? null;
if (!$id) json_error('Invoice ID is required', 422);
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
try {
// 1. Fetch Invoice (Super Admin sees all, others are tenant-scoped)
if ($role === 'super_admin') {
$stmt = $db->prepare("
SELECT i.*, c.name as company_name
FROM invoices i
JOIN companies c ON i.company_id = c.id
WHERE i.id = ?
");
$stmt->execute([$id]);
} else {
$stmt = $db->prepare("
SELECT i.*, c.name as company_name
FROM invoices i
JOIN companies c ON i.company_id = c.id
WHERE i.id = ? AND i.tenant_id = ?
");
$stmt->execute([$id, $tenantId]);
}
$invoice = $stmt->fetch();
if (!$invoice) json_error('Invoice not found or access denied', 404);
// 2. Fetch Line Items
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
$stmtLines->execute([$id]);
$invoice['items'] = $stmtLines->fetchAll();
// 3. Decrypt all encrypted fields — robust: if decryption fails, keep original value
$dec = function($val) {
if (empty($val)) return '';
$result = \App\Core\Encryption::decrypt((string)$val);
return ($result !== false && $result !== null) ? $result : (string)$val;
};
$invoice['supplier_tin'] = $dec($invoice['supplier_tin']);
$invoice['supplier_name'] = $dec($invoice['supplier_name']);
$invoice['supplier_address'] = $dec($invoice['supplier_address']);
$invoice['buyer_tin'] = $dec($invoice['buyer_tin']);
$invoice['buyer_name'] = $dec($invoice['buyer_name']);
$invoice['buyer_national_id'] = $dec($invoice['buyer_national_id']);
// company_name is stored plaintext in the companies table — no decryption needed
// $invoice['company_name'] is already plaintext from the JOIN
// 3.5 Parse Validation Warnings
if (isset($invoice['validation_warnings']) && $invoice['validation_warnings']) {
$invoice['validation_warnings'] = json_decode($invoice['validation_warnings'], true);
} else {
$invoice['validation_warnings'] = [];
}
// 4. Fetch JoFotara Submission Data (latest accepted submission)
$stmtSub = $db->prepare("
SELECT jofotara_uuid, submitted_at, qr_code_raw, response_body
FROM jofotara_submissions
WHERE invoice_id = ? AND status = 'accepted'
ORDER BY created_at DESC LIMIT 1
");
$stmtSub->execute([$id]);
$submission = $stmtSub->fetch();
if ($submission) {
$invoice['jofotara'] = [
'uuid' => $submission['jofotara_uuid'],
'submitted_at' => $submission['submitted_at'],
'qr_image_uri' => $submission['qr_code_raw']
? 'data:image/png;base64,' . $submission['qr_code_raw']
: null,
'has_xml' => true,
];
} else {
$invoice['jofotara'] = null;
}
// 5. Build the secure file URL with token (for Image.network compatibility)
$authHeader = getallheaders()['Authorization'] ?? getallheaders()['authorization'] ?? '';
$token = '';
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . urlencode($id) . '&token=' . $token;
// 6. Include local QR code from invoices table if available
// (This is used as a fallback in shell.php if jofotara object is missing)
json_success($invoice);
} catch (\Exception $e) {
error_log("Invoice View Error: " . $e->getMessage());
json_error('Server error during invoice retrieval', 500);
}

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

@@ -0,0 +1,95 @@
<?php
/**
* Assign/Upgrade Subscription Plan (Super Admin only)
* POST /api/v1/subscriptions/assign
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
// Only Super Admin can change plans manually via this API
if ($decoded['role'] !== 'super_admin') {
json_error('غير مصرح لك بتغيير الباقات. يرجى التواصل مع الدعم الفني.', 403);
}
$data = input();
$targetTenantId = $data['tenant_id'] ?? null;
$planId = $data['plan_id'] ?? null;
$durationDays = (int)($data['duration_days'] ?? 30);
if (!$targetTenantId || !$planId) {
json_error('معرف المكتب ومعرف الباقة مطلوبان.', 422);
}
$db = Database::getInstance();
try {
// 1. Validate Plan
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$planId]);
$plan = $stmt->fetch();
if (!$plan) {
json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422);
}
// 2. Update or Create Subscription
$db->beginTransaction();
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime("+{$durationDays} days"));
$stmt = $db->prepare("
INSERT INTO subscriptions (
tenant_id, plan_id, max_companies, max_invoices_per_month, max_users,
price_jod, status, current_period_start, current_period_end, updated_at
) VALUES (
:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()
)
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $targetTenantId,
'p_id' => $planId,
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
// 3. Log the change
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'plan_assigned', 'tenant', ?, ?)");
$logStmt->execute([
$targetTenantId,
$decoded['user_id'],
$targetTenantId,
json_encode(['plan_id' => $planId, 'assigned_by' => $decoded['user_id']])
]);
$db->commit();
json_success([
'tenant_id' => $targetTenantId,
'plan_id' => $planId,
'period_end' => $endDate
], 'تم تحديث باقة الاشتراك بنجاح');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Subscription Assign Error: " . $e->getMessage());
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* Get Current Tenant Subscription & Usage
* GET /api/v1/subscriptions/current
*/
use App\Middleware\AuthMiddleware;
use App\Middleware\QuotaMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
try {
$usage = QuotaMiddleware::getUsageSummary($tenantId);
if (!$usage['has_subscription']) {
json_error('لم يتم العثور على اشتراك نشط لهذا المكتب.', 404);
}
json_success($usage, 'تفاصيل الاشتراك الحالي');
} catch (\Exception $e) {
error_log("Subscription Current Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب بيانات الاشتراك', 500);
}

Some files were not shown because too many files have changed in this diff Show More