Compare commits

...

227 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
Hamza-Ayed
ff8f525c76 Update: 2026-05-03 21:32:45 2026-05-03 21:32:45 +03:00
Hamza-Ayed
214d96ee8d Security Hardening: Phase 1-3 complete
- C1: Hash refresh tokens before DB storage (sha256)
- C2: Remove JWT_SECRET fallback, fail hard if missing
- H1: Enforce HTTP methods per route (405 on mismatch)
- H2: CORS with origin whitelist from CORS_ORIGIN env var
- H3: Redact sensitive fields (tokens, passwords) from logs
- M1: Build HmacMiddleware with replay attack prevention
- M2: Fix rate limiter race condition with flock LOCK_EX
- M3: Guard dd() — suppressed in production
- M4: Remove .env from git tracking, strengthen .gitignore
- I1: Add HSTS header (max-age=31536000)
2026-05-03 21:06:17 +03:00
Hamza-Ayed
b33513ebcf Update: 2026-05-03 20:56:55 2026-05-03 20:56:55 +03:00
Hamza-Ayed
8af74f0621 Update: 2026-05-03 20:51:50 2026-05-03 20:51:50 +03:00
Hamza-Ayed
b0e79fd214 Update: 2026-05-03 20:47:13 2026-05-03 20:47:13 +03:00
Hamza-Ayed
bc35319f3c Update: 2026-05-03 20:45:16 2026-05-03 20:45:16 +03:00
Hamza-Ayed
e2acce23c0 Fix case sensitivity in app folders 2026-05-03 20:33:29 +03:00
Hamza-Ayed
f78c8f5864 Update: 2026-05-03 20:23:16 2026-05-03 20:23:16 +03:00
Hamza-Ayed
fdd850e3af Update: 2026-05-03 18:21:19 2026-05-03 18:21:19 +03:00
Hamza-Ayed
2c8ed7e742 Update: 2026-05-03 18:19:24 2026-05-03 18:19:24 +03:00
Hamza-Ayed
59d766c6d7 Update: 2026-05-03 18:15:49 2026-05-03 18:15:49 +03:00
Hamza-Ayed
0d458e8d81 Update: 2026-05-03 18:12:07 2026-05-03 18:12:07 +03:00
Hamza-Ayed
501fd96dc1 Update: 2026-05-03 18:01:58 2026-05-03 18:01:58 +03:00
Hamza-Ayed
8c584625da Update: 2026-05-03 17:35:15 2026-05-03 17:35:15 +03:00
Hamza-Ayed
4b40b1185f Update: 2026-05-03 17:32:57 2026-05-03 17:32:57 +03:00
445 changed files with 42956 additions and 11287 deletions

BIN
.DS_Store vendored

Binary file not shown.

45
.env
View File

@@ -1,45 +0,0 @@
APP_NAME="مُصادَق"
APP_ENV=development
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Amman
# MySQL (CloudPanel managed)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=musadaqDb
DB_USERNAME=musadaqUser
DB_PASSWORD=FWVG3vx2fhrwUULXa6E4
DB_CHARSET=utf8mb4
# Redis (system service)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
# JWT
JWT_SECRET=ec7f91fe8a83c3889902d8e678dfda9cbeba48576b49b2027dcbd010c3d2bbf4
ENCRYPTION_KEY_B64=0AEcpckd2g6eMA3ofBXRpgrDbV6ExWkB+D1Hl5pE+I0=
JWT_ACCESS_EXPIRY=900
JWT_REFRESH_EXPIRY=604800
# AI Providers
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
# JoFotara
JOFOTARA_BASE_URL=https://backend.jofotara.gov.jo/core/invoices
JOFOTARA_ENV=sandbox
# Email
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=noreply@musadaq.app
MAIL_FROM_NAME="مُصادَق"
# Storage
STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage
UPLOAD_MAX_SIZE=20971520

11
.gitignore vendored
View File

@@ -1,12 +1,21 @@
# Secrets — NEVER commit these
.env .env
.env.*
config/secrets.php config/secrets.php
# Storage — runtime data, not code
storage/invoices/ storage/invoices/
storage/logs/ storage/logs/
storage/exports/ storage/exports/
storage/cache/
# Dependencies
vendor/ vendor/
node_modules/
# Dev tools
scratch.js scratch.js
describe.php describe.php
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
node_modules/

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

View File

@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Dotenv\Dotenv;
use App\Core\{Request, Response, Router, Container};
final class Application
{
private Container $container;
private Router $router;
public static ?array $config = null;
public function __construct(string $basePath)
{
// 1. Load Environment Variables
// In local dev, .env is in the project root. In production, it might be moved.
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
// 2. Set Timezone
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Asia/Amman');
// 3. Initialize Core Components
$this->container = new Container();
// 4. Load Configurations
$this->loadConfigs($basePath);
$this->router = new Router($this->container);
// Register core services in container
$this->container->set(Container::class, $this->container);
$this->container->set(Router::class, $this->router);
}
private function loadConfigs(string $basePath): void
{
$configPath = $basePath . '/config';
$configs = [];
foreach (glob($configPath . '/*.php') as $file) {
$key = basename($file, '.php');
$configs[$key] = require $file;
}
self::$config = $configs;
$this->container->set('config', $configs);
}
public function getRouter(): Router
{
return $this->router;
}
public function run(): void
{
// 1. Security Headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Content-Security-Policy: default-src \'self\'; script-src \'self\' cdn.tailwindcss.com unpkg.com; style-src \'self\' \'unsafe-inline\' fonts.googleapis.com; font-src fonts.gstatic.com');
header_remove('X-Powered-By');
try {
$request = new Request();
$this->router->dispatch($request, $this->container);
} catch (\Throwable $e) {
// Global Exception Handler
Response::error(
'حدث خطأ غير متوقع في النظام',
'INTERNAL_SERVER_ERROR',
500,
[
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]
);
}
}
}

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

@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Exception;
use ReflectionClass;
use ReflectionNamedType;
final class Container
{
private array $instances = [];
public function set(string $id, mixed $concrete): void
{
$this->instances[$id] = $concrete;
}
public function get(string $id): mixed
{
if (isset($this->instances[$id])) {
if ($this->instances[$id] instanceof \Closure) {
$this->instances[$id] = ($this->instances[$id])($this);
}
return $this->instances[$id];
}
return $this->resolve($id);
}
public function resolve(string $id): mixed
{
if (!class_exists($id)) {
throw new Exception("Class {$id} cannot be resolved.");
}
$reflection = new ReflectionClass($id);
if (!$reflection->isInstantiable()) {
throw new Exception("Class {$id} is not instantiable.");
}
$constructor = $reflection->getConstructor();
if (is_null($constructor)) {
return new $id();
}
$parameters = $constructor->getParameters();
$dependencies = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
continue;
}
throw new Exception("Unable to resolve parameter '{$parameter->getName()}' in class {$id}");
}
$dependencies[] = $this->get($type->getName());
}
$instance = $reflection->newInstanceArgs($dependencies);
$this->instances[$id] = $instance;
return $instance;
}
}

View File

@@ -1,4 +1,7 @@
<?php <?php
/**
* Simple PDO Database Wrapper
*/
declare(strict_types=1); declare(strict_types=1);
@@ -6,7 +9,6 @@ namespace App\Core;
use PDO; use PDO;
use PDOException; use PDOException;
use Exception;
final class Database final class Database
{ {
@@ -15,27 +17,35 @@ final class Database
public static function getInstance(): PDO public static function getInstance(): PDO
{ {
if (self::$instance === null) { if (self::$instance === null) {
$host = $_ENV['DB_HOST']; $config = require APP_PATH . '/config/database.php';
$db = $_ENV['DB_DATABASE'];
$user = $_ENV['DB_USERNAME']; $dsn = sprintf(
$pass = $_ENV['DB_PASSWORD']; "mysql:host=%s;port=%s;dbname=%s;charset=%s",
$port = $_ENV['DB_PORT']; $config['host'],
$charset = $_ENV['DB_CHARSET'] ?? 'utf8mb4'; $config['port'],
$config['database'],
$dsn = "mysql:host=$host;dbname=$db;port=$port;charset=$charset"; $config['charset']
$options = [ );
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try { try {
self::$instance = new PDO($dsn, $user, $pass, $options); self::$instance = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) { } catch (PDOException $e) {
throw new Exception("Database Connection Error: " . $e->getMessage()); http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Database connection failed']);
exit;
} }
} }
return self::$instance; return self::$instance;
} }
public static function generateUuid(): string
{
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
}
} }

81
app/Core/Encryption.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/**
* Advanced Encryption (AES-256-GCM) - System Level
*/
declare(strict_types=1);
namespace App\Core;
final class Encryption
{
private const CIPHER = 'aes-256-gcm';
/**
* Encrypts data using the system's ENCRYPTION_KEY from .env
*/
public static function encrypt(string $data): string
{
$key = env('ENCRYPTION_KEY');
if (!$key) {
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
}
$encryptionKey = hash('sha256', $key, true);
$iv = random_bytes(openssl_cipher_iv_length(self::CIPHER));
$tag = '';
$ciphertext = openssl_encrypt($data, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
if ($ciphertext === false) {
throw new \RuntimeException('Encryption failed');
}
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Decrypts AES-256-GCM encrypted data using the system's ENCRYPTION_KEY
*/
public static function decrypt(string $encryptedData): string|false
{
$key = env('ENCRYPTION_KEY');
if (!$key) {
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
}
// Handle common prefixing issues or trailing whitespace
$encryptedData = trim($encryptedData);
if (str_starts_with($encryptedData, '==')) {
$encryptedData = substr($encryptedData, 2);
}
$encryptionKey = hash('sha256', $key, true);
$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) {
// 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);
$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;
}
}

52
app/Core/JWT.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
/**
* Simple JWT (HMAC SHA256)
*/
declare(strict_types=1);
namespace App\Core;
final class JWT
{
private static function base64UrlEncode(string $data): string
{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
public static function encode(array $payload, string $secret): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode(json_encode($payload));
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
public static function decode(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header, $payload, $signature] = $parts;
$expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $header . "." . $payload, $secret, true));
if (!hash_equals($expectedSignature, $signature)) return null;
$decodedPayload = json_decode(self::base64UrlDecode($payload), true);
// Check expiry
if (isset($decodedPayload['exp']) && $decodedPayload['exp'] < time()) return null;
return $decodedPayload;
}
}

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

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Predis\Client;
use Exception;
final class Redis
{
private static ?Client $instance = null;
public static function getInstance(): Client
{
if (self::$instance === null) {
try {
self::$instance = new Client([
'scheme' => 'tcp',
'host' => $_ENV['REDIS_HOST'] ?? '127.0.0.1',
'port' => $_ENV['REDIS_PORT'] ?? 6379,
'password' => $_ENV['REDIS_PASSWORD'] ?: null,
]);
} catch (Exception $e) {
// If Redis fails, we might want to log it or handle gracefully
// depending on how critical it is.
throw new Exception("Redis Connection Error: " . $e->getMessage());
}
}
return self::$instance;
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class Request
{
private string $method;
private string $path;
private array $headers;
private array $queryParams;
private array $body;
private array $files;
public ?object $user = null; // Populated by AuthMiddleware
public ?string $tenantId = null; // Populated by TenantMiddleware
public function __construct()
{
$this->method = $_SERVER['REQUEST_METHOD'];
// Read API path from query string: index.php?route=/api/v1/auth/login
$this->path = $_GET['route'] ?? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$this->headers = getallheaders();
$this->queryParams = $_GET;
$this->files = $_FILES;
$contentType = $this->getHeader('Content-Type') ?? $_SERVER['CONTENT_TYPE'] ?? '';
if ($contentType && str_contains(strtolower($contentType), 'application/json')) {
$this->body = json_decode(file_get_contents('php://input'), true) ?? [];
} else {
$this->body = $_POST;
}
}
public function getMethod(): string { return $this->method; }
public function getPath(): string { return $this->path; }
public function getHeaders(): array { return $this->headers; }
public function getQueryParams(): array { return $this->queryParams; }
public function getBody(): array { return $this->body; }
public function getFiles(): array { return $this->files; }
public function getHeader(string $name): ?string
{
$name = strtolower($name);
foreach ($this->headers as $key => $value) {
if (strtolower($key) === $name) {
return $value;
}
}
return null;
}
public function input(string $key, mixed $default = null): mixed
{
return $this->body[$key] ?? $this->queryParams[$key] ?? $default;
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
final class Response
{
public static function json(array $data, int $status = 200, array $headers = []): void
{
self::send($data, $status, array_merge(['Content-Type' => 'application/json; charset=utf-8'], $headers));
}
public static function error(string $messageAr, string $code, int $status = 400, ?array $details = null): void
{
$data = [
'success' => false,
'error' => [
'message_ar' => $messageAr,
'code' => $code,
'details' => $details
]
];
self::json($data, $status);
}
private static function send(mixed $data, int $status, array $headers): void
{
http_response_code($status);
foreach ($headers as $name => $value) {
header("$name: $value");
}
// Apply Security Headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header_remove('X-Powered-By');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
}

View File

@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use FastRoute\RouteCollector;
use function FastRoute\simpleDispatcher;
final class Router
{
private array $routes = [];
public Container $container;
public function __construct(Container $container)
{
$this->container = $container;
}
public function addRoute(string $method, string $path, array|callable $handler): void
{
$this->routes[] = [$method, $path, $handler];
}
public function dispatch(Request $request): void
{
$dispatcher = simpleDispatcher(function (RouteCollector $r) {
foreach ($this->routes as $route) {
$r->addRoute($route[0], $route[1], $route[2]);
}
});
$routeInfo = $dispatcher->dispatch($request->getMethod(), $request->getPath());
switch ($routeInfo[0]) {
case \FastRoute\Dispatcher::NOT_FOUND:
Response::error('المسار غير موجود', 'NOT_FOUND', 404);
break;
case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
Response::error('الطريقة غير مسموح بها', 'METHOD_NOT_ALLOWED', 405);
break;
case \FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];
$this->executeHandler($handler, $request, $this->container, $vars);
break;
}
}
private function executeHandler(mixed $handler, Request $request, Container $container, array $vars): void
{
if (is_array($handler) && isset($handler['middleware'])) {
$middlewares = (array) $handler['middleware'];
$finalHandler = $handler['handler'];
$pipeline = $this->createPipeline($middlewares, $finalHandler, $container, $vars);
$pipeline($request);
} else {
$this->callHandler($handler, $request, $container, $vars);
}
}
private function createPipeline(array $middlewares, mixed $handler, Container $container, array $vars): callable
{
return array_reduce(
array_reverse($middlewares),
function ($next, $middleware) use ($container) {
return function ($request) use ($next, $middleware, $container) {
$parts = explode(':', $middleware);
$className = $parts[0];
$args = isset($parts[1]) ? explode(',', $parts[1]) : [];
$instance = $container->get($className);
return $instance->handle($request, $next, ...$args);
};
},
function ($request) use ($handler, $container, $vars) {
$this->callHandler($handler, $request, $container, $vars);
}
);
}
private function callHandler(mixed $handler, Request $request, Container $container, array $vars): void
{
if (is_array($handler)) {
[$controllerClass, $method] = $handler;
$controller = $container->get($controllerClass);
$controller->$method($request, ...array_values($vars));
} else {
$handler($request, ...array_values($vars));
}
}
}

42
app/Core/Security.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
/**
* Simple Security Helpers
*/
declare(strict_types=1);
namespace App\Core;
final class Security
{
/**
* Recursively sanitize input data (strings and arrays)
*/
public static function sanitize($data)
{
if (is_array($data)) {
foreach ($data as $key => $value) {
$data[$key] = self::sanitize($value);
}
} else if (is_string($data)) {
$data = htmlspecialchars(strip_tags(trim($data)), ENT_QUOTES, 'UTF-8');
}
return $data;
}
public static function generateRandomString(int $length = 64): string
{
return bin2hex(random_bytes($length / 2));
}
public static function sign(string $data, string $secret): string
{
return hash_hmac('sha256', $data, $secret);
}
public static function verifySignature(string $data, string $signature, string $secret): bool
{
$expected = self::sign($data, $secret);
return hash_equals($expected, $signature);
}
}

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

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
if (!function_exists('config')) {
/**
* Get a configuration value using dot notation.
* Example: config('app.name')
*/
function config(string $key, mixed $default = null): mixed
{
$configs = \App\Core\Application::$config;
if ($configs === null) {
return $default;
}
$parts = explode('.', $key);
$value = $configs;
foreach ($parts as $part) {
if (!isset($value[$part])) {
return $default;
}
$value = $value[$part];
}
return $value;
}
}
if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
}

View File

@@ -1,53 +1,49 @@
<?php <?php
/**
* Simple Authentication Middleware
*/
declare(strict_types=1); declare(strict_types=1);
namespace App\Middleware; namespace App\Middleware;
use App\Core\{Request, Response}; use App\Core\JWT;
use App\Services\Security\JwtService;
use Exception;
final class AuthMiddleware final class AuthMiddleware
{ {
public function __construct(private readonly JwtService $jwtService) {} public static function check(): array
public function handle(Request $request, callable $next): mixed
{ {
$authHeader = $request->getHeader('Authorization'); $headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401); if (!str_starts_with($authHeader, 'Bearer ')) {
return null; json_error('Unauthorized: Missing or invalid token', 401);
} }
$token = substr($authHeader, 7); $token = substr($authHeader, 7);
$secret = env('JWT_SECRET');
try {
$decoded = $this->jwtService->verifyToken($token); if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short');
// Check if JTI is blacklisted json_error('Server configuration error', 500);
$jti = $decoded['jti'] ?? null; }
if ($jti) {
try { $decoded = JWT::decode($token, $secret);
$redis = \App\Core\Redis::getInstance();
if ($redis->exists('jwt_blacklist:' . $jti)) { if (!$decoded) {
Response::error('الجلسة منتهية، يرجى تسجيل الدخول من جديد', 'TOKEN_REVOKED', 401); // Check if it's specifically expired if your JWT class supports it,
return null; // otherwise just send the standard 401 with a code.
} http_response_code(401);
} catch (\Throwable $e) { header('Content-Type: application/json');
// Redis down — allow (fail open, log security event) echo json_encode([
error_log('[AUTH] JWT blacklist check failed: ' . $e->getMessage()); 'success' => false,
} 'message' => 'انتهت صلاحية الجلسة',
} 'code' => 'TOKEN_EXPIRED',
'redirect'=> '/login.php'
$request->user = (object) $decoded; ]);
$request->tenantId = $decoded['tenant_id'] ?? null; exit;
} catch (Exception $e) {
Response::error('جلسة العمل منتهية أو غير صالحة', 'UNAUTHORIZED', 401);
return null;
} }
return $next($request); 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

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
final class CsrfMiddleware
{
public function handle(Request $request, callable $next): mixed
{
// Skip CSRF check for safe methods
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
return $next($request);
}
// For APIs, we often use a custom header or check origin
// If we use sessions for tokens:
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = $request->getHeader('X-CSRF-TOKEN') ?: ($request->getBody()['_csrf'] ?? null);
$sessionToken = $_SESSION['csrf_token'] ?? null;
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) {
// For now, if we are purely API with Bearer token, we might skip this.
// But if the request has a session or cookie, it's mandatory.
// If the Authorization header is present, we might assume it's an API call
// that is naturally protected against CSRF if not using cookies for Auth.
if ($request->getHeader('Authorization')) {
return $next($request);
}
Response::error('رمز الحماية (CSRF) غير صالح أو مفقود', 'CSRF_INVALID', 403);
return null;
}
return $next($request);
}
}

View File

@@ -1,60 +1,62 @@
<?php <?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); declare(strict_types=1);
namespace App\Middleware; namespace App\Middleware;
use App\Core\{Request, Response, Redis}; use App\Core\Security;
use App\Services\Security\HmacService;
use App\Core\Database;
final class HmacMiddleware final class HmacMiddleware
{ {
public function __construct(private readonly HmacService $hmac) {} /**
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
public function handle(Request $request, callable $next): mixed */
public static function verify(int $maxAgeSeconds = 300): void
{ {
$publicKey = $request->getHeader('X-Api-Key'); $headers = getallheaders();
$signature = $request->getHeader('X-Signature'); $signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
$timestamp = $request->getHeader('X-Timestamp'); $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
$nonce = $request->getHeader('X-Nonce');
if (!$publicKey || !$signature || !$timestamp || !$nonce) { // 1. Ensure both headers are present
Response::error('بيانات التوقيع (HMAC) ناقصة', 'HMAC_MISSING', 401); if (empty($signature) || empty($timestamp)) {
return null; json_error('Missing HMAC signature or timestamp', 401);
} }
// 1. Lookup Secret by Public Key // 2. Validate timestamp is numeric
$db = Database::getInstance(); if (!ctype_digit((string)$timestamp)) {
$stmt = $db->prepare("SELECT secret_hash, tenant_id FROM api_keys WHERE public_key = ? AND is_active = 1 LIMIT 1"); json_error('Invalid timestamp format', 401);
$stmt->execute([$publicKey]);
$apiKey = $stmt->fetch();
if (!$apiKey) {
Response::error('مفتاح API غير صالح', 'HMAC_INVALID_KEY', 401);
return null;
} }
// 2. Verify Signature // 3. Replay attack prevention — reject stale requests
// Note: secret_hash in DB is the actual secret for signing $age = abs(time() - (int)$timestamp);
$isValid = $this->hmac->verify( if ($age > $maxAgeSeconds) {
$apiKey['secret_hash'], json_error('Request expired. Check your system clock.', 401);
$request->getMethod(),
$request->getPath(),
$timestamp,
$nonce,
json_encode($request->getBody()),
$signature
);
if (!$isValid) {
Response::error('توقيع الطلب غير صحيح', 'HMAC_INVALID_SIGNATURE', 401);
return null;
} }
// 3. Set context // 4. Build the expected signature
$request->tenantId = $apiKey['tenant_id']; $body = file_get_contents('php://input');
$payload = $timestamp . '.' . $body;
$secret = env('HMAC_SECRET_KEY');
return $next($request); 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

@@ -1,36 +1,78 @@
<?php <?php
/**
* Rate Limiting Middleware (File-based, Race-Condition Safe)
*/
declare(strict_types=1); declare(strict_types=1);
namespace App\Middleware; namespace App\Middleware;
use App\Core\{Request, Response, Redis};
final class RateLimitMiddleware final class RateLimitMiddleware
{ {
/** /**
* @param int $limit Requests allowed * File-based rate limiter with file-lock to prevent race conditions.
* @param int $window Seconds window * For multi-server deployments, replace with Redis.
*/ */
public function handle(Request $request, callable $next, int $limit = 60, int $window = 60): mixed public static function check(int $maxRequests = 60, int $timeWindow = 60): void
{ {
$redis = Redis::getInstance(); $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$ip = $_SERVER['REMOTE_ADDR']; $key = 'rl:' . md5($ip);
$key = "ratelimit:" . md5($request->getPath() . "|" . $ip);
$current = $redis->get($key); // 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 ($current && (int)$current >= $limit) { if (!$count) {
Response::error('لقد تجاوزت الحد المسموح من الطلبات، يرجى المحاولة لاحقاً', 'RATE_LIMIT_EXCEEDED', 429); $redis->setex($key, $timeWindow, 1);
return null; } else {
$redis->incr($key);
}
return; // Success with Redis
} catch (\Exception $e) {
// Fallback to file-based if Redis fails
}
} }
if (!$current) { // 2. Fallback: File-based rate limiter (original logic)
$redis->setex($key, $window, 1); $cacheDir = STORAGE_PATH . '/cache';
} else { $cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
$redis->incr($key); if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
}
return $next($request); $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

@@ -1,37 +1,97 @@
<?php <?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); declare(strict_types=1);
namespace App\Middleware; namespace App\Middleware;
use App\Core\{Request, Response};
final class RoleMiddleware final class RoleMiddleware
{ {
/** /**
* Handle the request. * Require the user to have ONE of the specified roles.
* * Halts execution with 403 if the user doesn't have any of them.
* @param Request $request
* @param callable $next
* @param string ...$roles
* @return mixed
*/ */
public function handle(Request $request, callable $next, string ...$roles): mixed public static function require(array $allowedRoles, ?array $decoded = null): array
{ {
$user = $request->user ?? null; if (!$decoded) {
$decoded = AuthMiddleware::check();
if (!$user) {
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401);
return null;
} }
// Check if user role is in the allowed roles $userRole = $decoded['role'] ?? '';
// $user->role is an object property since we cast it in AuthMiddleware
if (!in_array($user->role, $roles)) { if (!in_array($userRole, $allowedRoles, true)) {
Response::error('غير مسموح لك بالقيام بهذا الإجراء', 'FORBIDDEN', 403); http_response_code(403);
return null; header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'ليس لديك صلاحية للوصول إلى هذا المورد',
'code' => 'FORBIDDEN',
'required_roles' => $allowedRoles,
'your_role' => $userRole,
], JSON_UNESCAPED_UNICODE);
exit;
} }
return $next($request); 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

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response, Database};
final class TenantMiddleware
{
public function handle(Request $request, callable $next): mixed
{
$tenantId = $request->tenantId ?? null;
if (!$tenantId) {
Response::error('المستأجر غير معروف', 'TENANT_NOT_FOUND', 400);
return null;
}
// Check if tenant exists and is active
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT status FROM tenants WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
$tenant = $stmt->fetch();
if (!$tenant) {
Response::error('المستأجر غير موجود', 'TENANT_NOT_FOUND', 404);
return null;
}
if ($tenant['status'] === 'suspended') {
Response::error('تم إيقاف حساب المستأجر', 'TENANT_SUSPENDED', 403);
return null;
}
} catch (\Exception $e) {
Response::error('خطأ في الاتصال بقاعدة البيانات', 'DATABASE_ERROR', 500);
return null;
}
return $next($request);
}
}

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Core\Database;
use PDO;
abstract class BaseModel
{
protected string $table;
protected string $primaryKey = 'id';
protected array $fillable = [];
protected function db(): PDO
{
return Database::getInstance();
}
public function find(string $id): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
public function create(array $data): string|bool
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
$stmt = $this->db()->prepare($sql);
if ($stmt->execute(array_values($data))) {
return $data[$this->primaryKey] ?? $this->db()->lastInsertId();
}
return false;
}
public function update(string $id, array $data): bool
{
$sets = [];
foreach (array_keys($data) as $column) {
$sets[] = "{$column} = ?";
}
$setString = implode(', ', $sets);
$sql = "UPDATE {$this->table} SET {$setString} WHERE {$this->primaryKey} = ?";
$stmt = $this->db()->prepare($sql);
$params = array_values($data);
$params[] = $id;
return $stmt->execute($params);
}
public function delete(string $id): bool
{
$sql = "UPDATE {$this->table} SET deleted_at = NOW() WHERE {$this->primaryKey} = ?";
$stmt = $this->db()->prepare($sql);
return $stmt->execute([$id]);
}
}

View File

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\AI;
use App\Core\{Request, Response, Database};
use GuzzleHttp\Client;
use Throwable;
final class AIController
{
private Client $httpClient;
private string $apiKey;
private string $model;
public function __construct()
{
$this->httpClient = new Client();
$this->apiKey = $_ENV['GEMINI_API_KEY'] ?? '';
$this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash';
}
public function query(Request $request): void
{
$userQuery = $request->input('query');
if (!$userQuery) {
Response::error('يرجى تقديم استفسار', 'MISSING_QUERY', 422);
return;
}
try {
// 1. Fetch current context data (Summary of stats)
$stats = $this->getQuickStats($request->tenantId);
// 2. Ask Gemini to interpret and answer
$prompt = "You are Musadaq AI Assistant for a Jordanian E-Invoicing SaaS. " .
"The user is asking: \"{$userQuery}\". " .
"Current User Context: Tenant ID {$request->tenantId}. " .
"Current Data Summary: " . json_encode($stats) . ". " .
"Answer the user in a friendly Arabic tone (Jordanian dialect is okay). " .
"Keep it professional and concise. If you don't have the specific data, say so politely.";
$response = $this->httpClient->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [
'json' => [
'contents' => [['parts' => [['text' => $prompt]]]]
]
]);
$data = json_decode($response->getBody()->getContents(), true);
$answer = $data['candidates'][0]['content']['parts'][0]['text'] ?? 'عذراً، لم أستطع فهم الاستفسار حالياً.';
Response::json([
'success' => true,
'data' => [
'answer' => $answer
]
]);
} catch (Throwable $e) {
Response::error('فشل معالجة الاستعلام الذكي', 'AI_QUERY_FAILED', 500, [
'error' => $e->getMessage()
]);
}
}
private function getQuickStats(string $tenantId): array
{
$db = Database::getInstance();
$totalInvoices = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ?");
$totalInvoices->execute([$tenantId]);
$approvedCount = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ? AND status = 'approved'");
$approvedCount->execute([$tenantId]);
return [
'total_invoices' => $totalInvoices->fetch()['total'],
'approved_invoices' => $approvedCount->fetch()['total'],
'current_month' => date('F Y')
];
}
}

View File

@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Admin;
use App\Core\{Request, Response, Database};
final class AdminController
{
public function listTenants(Request $request): void
{
if ($request->user->role !== 'super_admin') {
Response::error('غير مصرح لك بالوصول لهذه البيانات', 'FORBIDDEN', 403);
return;
}
$db = Database::getInstance();
$stmt = $db->prepare("SELECT t.*, (SELECT COUNT(*) FROM invoices WHERE tenant_id = t.id) as invoice_count FROM tenants t");
$stmt->execute();
$tenants = $stmt->fetchAll();
Response::json(['success' => true, 'data' => $tenants]);
}
public function getSystemStats(Request $request): void
{
if ($request->user->role !== 'super_admin') {
Response::error('Forbidden', 'FORBIDDEN', 403);
return;
}
$db = Database::getInstance();
$stats = [
'total_tenants' => (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn(),
'total_invoices' => (int)$db->query("SELECT COUNT(*) FROM invoices")->fetchColumn(),
'total_users' => (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn(),
'active_subscriptions' => (int)$db->query("SELECT COUNT(*) FROM subscriptions WHERE status = 'active'")->fetchColumn()
];
Response::json(['success' => true, 'data' => $stats]);
}
public function getQueueStatus(Request $request): void
{
if ($request->user->role !== 'super_admin') {
Response::error('Forbidden', 'FORBIDDEN', 403);
return;
}
$db = Database::getInstance();
$stmt = $db->prepare("SELECT status, COUNT(*) as count FROM queue_jobs GROUP BY status");
$stmt->execute();
$counts = $stmt->fetchAll();
Response::json(['success' => true, 'data' => $counts]);
}
public function health(Request $request): void
{
$dbStatus = 'ok';
try { Database::getInstance()->query("SELECT 1"); } catch (\Throwable $e) { $dbStatus = 'error'; }
$redisStatus = 'ok';
try { \App\Core\Redis::getInstance()->ping(); } catch (\Throwable $e) { $redisStatus = 'error'; }
Response::json([
'success' => true,
'data' => [
'database' => $dbStatus,
'redis' => $redisStatus,
'php_version' => PHP_VERSION,
'server_time' => date('Y-m-d H:i:s')
]
]);
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ApiKeys;
use App\Core\{Request, Response, Database};
final class ApiKeyController
{
public function index(Request $request): void
{
$tenantId = $request->tenantId;
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id, public_key, name, is_active, created_at FROM api_keys WHERE tenant_id = ? AND is_active = 1");
$stmt->execute([$tenantId]);
$keys = $stmt->fetchAll();
Response::json(['success' => true, 'data' => $keys]);
}
public function create(Request $request): void
{
$tenantId = $request->tenantId;
$data = $request->getBody();
$name = $data['name'] ?? 'Default Key';
$publicKey = bin2hex(random_bytes(16)); // 32 chars
$secret = bin2hex(random_bytes(32)); // 64 chars
$db = Database::getInstance();
$stmt = $db->prepare("INSERT INTO api_keys (id, tenant_id, name, public_key, secret_hash, is_active, created_at) VALUES (?, ?, ?, ?, ?, 1, NOW())");
$id = \Ramsey\Uuid\Uuid::uuid4()->toString();
$stmt->execute([
$id,
$tenantId,
$name,
$publicKey,
password_hash($secret, PASSWORD_BCRYPT)
]);
Response::json([
'success' => true,
'message' => 'تم إنشاء مفتاح API بنجاح. يرجى حفظ السر (Secret) الآن لأنه لن يظهر مرة أخرى.',
'data' => [
'id' => $id,
'public_key' => $publicKey,
'secret' => $secret
]
], 201);
}
public function revoke(Request $request, string $id): void
{
$tenantId = $request->tenantId;
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE api_keys SET is_active = 0 WHERE id = ? AND tenant_id = ?");
$stmt->execute([$id, $tenantId]);
Response::json(['success' => true, 'message' => 'تم إيقاف مفتاح API بنجاح']);
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ApiKeys;
use App\Models\BaseModel;
final class ApiKeyModel extends BaseModel
{
protected string $table = 'api_keys';
public function findAllByTenant(string $tenantId): array
{
$stmt = $this->db()->prepare("SELECT id, name, prefix, expires_at, last_used_at, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
}

View File

@@ -1,189 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Core\{Request, Response};
use App\Modules\Auth\AuthService;
use Throwable;
final class AuthController
{
public function __construct(private readonly AuthService $authService) {}
public function login(Request $request): void
{
$email = $request->input('email');
$password = $request->input('password');
if (!$email || !$password) {
Response::error('يرجى إدخال البريد الإلكتروني وكلمة المرور', 'VALIDATION_ERROR', 422);
return;
}
try {
$result = $this->authService->login($email, $password);
// 2FA Check
if ($result['user']->totp_enabled) {
Response::json([
'success' => true,
'requires_2fa' => true,
'temp_token' => $result['access_token']
]);
return;
}
// Set refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
unset($result['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'message' => 'تم تسجيل الدخول بنجاح'
]);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'AUTH_FAILED', 401);
}
}
public function me(Request $request): void
{
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT id, tenant_id, name, email, role, totp_enabled FROM users WHERE id = ?");
$stmt->execute([$request->user->user_id]);
$user = $stmt->fetch();
Response::json([
'success' => true,
'data' => $user
]);
}
public function logout(Request $request): void
{
// Clear refresh token cookie
setcookie('refresh_token', '', [
'expires' => time() - 3600,
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
Response::json([
'success' => true,
'message' => 'تم تسجيل الخروج بنجاح'
]);
}
public function refresh(Request $request): void
{
$refreshToken = $_COOKIE['refresh_token'] ?? null;
if (!$refreshToken) {
Response::error('رمز التجديد مفقود', 'UNAUTHORIZED', 401);
return;
}
try {
$result = $this->authService->refresh($refreshToken);
// Set new refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
unset($result['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'message' => 'تم تجديد الجلسة بنجاح'
]);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'REFRESH_FAILED', 401);
}
}
public function register(Request $request): void
{
try {
$result = $this->authService->register($request->getBody());
// Set refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
unset($result['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'message' => 'تم إنشاء الحساب وتسجيل الدخول بنجاح'
]);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'REGISTRATION_FAILED', 400);
}
}
public function enable2FA(Request $request): void
{
$user = $request->user;
$totpService = new \App\Services\TotpService();
$secret = $totpService->generateSecret();
$qrUrl = $totpService->getQrCodeUrl($user->email, $secret);
Response::json([
'success' => true,
'data' => [
'secret' => $secret,
'qr_url' => $qrUrl
]
]);
}
public function verify2FA(Request $request): void
{
$data = $request->getBody();
$code = $data['code'] ?? '';
$secret = $data['secret'] ?? '';
$totpService = new \App\Services\TotpService();
if ($totpService->verify($secret, $code)) {
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?");
$stmt->execute([$secret, $request->user->user_id]);
Response::json(['success' => true, 'message' => 'تم تفعيل التحقق الثنائي بنجاح']);
} else {
Response::error('رمز التحقق غير صحيح', 'INVALID_CODE', 400);
}
}
public function disable2FA(Request $request): void
{
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?");
$stmt->execute([$request->user->user_id]);
Response::json(['success' => true, 'message' => 'تم تعطيل التحقق الثنائي']);
}
}

View File

@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Modules\Users\UserModel;
use App\Modules\Tenants\TenantModel;
use App\Modules\Subscriptions\SubscriptionModel;
use App\Services\Security\JwtService;
use Ramsey\Uuid\Uuid;
use Exception;
final class AuthService
{
public function __construct(
private readonly UserModel $userModel,
private readonly JwtService $jwtService,
private readonly TenantModel $tenantModel,
private readonly SubscriptionModel $subscriptionModel
) {}
public function login(string $email, string $password): array
{
$user = $this->userModel->findByEmail($email);
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new Exception("البريد الإلكتروني أو كلمة المرور غير صحيحة");
}
if (!$user['is_active']) {
throw new Exception("هذا الحساب معطل حالياً");
}
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]);
$refreshToken = $this->jwtService->issueRefreshToken($user['id']);
// Update refresh token hash in DB
$this->userModel->update($user['id'], [
'refresh_token_hash' => password_hash($refreshToken, PASSWORD_BCRYPT),
'last_login_at' => date('Y-m-d H:i:s'),
'last_login_ip' => $_SERVER['REMOTE_ADDR'] ?? null
]);
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]
];
}
public function refresh(string $refreshToken): array
{
$parts = explode('.', $refreshToken);
if (count($parts) !== 2) {
throw new Exception("رمز التجديد غير صالحة");
}
[$userId, $random] = $parts;
$user = $this->userModel->find($userId);
if (!$user || !$user['is_active']) {
throw new Exception("المستخدم غير موجود أو معطل");
}
if (!$user['refresh_token_hash'] || !password_verify($refreshToken, $user['refresh_token_hash'])) {
throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى");
}
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]);
$newRefreshToken = $this->jwtService->issueRefreshToken($user['id']);
$this->userModel->update($user['id'], [
'refresh_token_hash' => password_hash($newRefreshToken, PASSWORD_BCRYPT)
]);
return [
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]
];
}
public function register(array $data): array
{
// 1. Check if tenant already exists
if ($this->tenantModel->findByEmail($data['email'])) {
throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً");
}
$tenantId = Uuid::uuid4()->toString();
$userId = Uuid::uuid4()->toString();
// 2. Create Tenant
$this->tenantModel->create([
'id' => $tenantId,
'name' => $data['tenant_name'],
'email' => $data['email'],
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
]);
// 3. Create Subscription
$this->subscriptionModel->create([
'tenant_id' => $tenantId,
'plan' => 'basic',
'status' => 'trial'
]);
// 4. Create User
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['user_name'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => 'admin',
'is_active' => 1
]);
return $this->login($data['email'], $data['password']);
}
public function logout(string $jti, int $remaining): void
{
// Blacklist the JTI for its remaining lifetime
try {
$redis = \App\Core\Redis::getInstance();
$redis->setex('jwt_blacklist:' . $jti, max($remaining, 1), '1');
} catch (\Throwable $e) {
error_log('[AUTH] Could not blacklist JTI: ' . $e->getMessage());
}
}
}

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Core\{Request, Response};
use App\Modules\Companies\{CompanyModel, CompanyService};
use Throwable;
final class CompanyController
{
public function __construct(
private readonly CompanyModel $companyModel,
private readonly CompanyService $companyService
) {}
public function list(Request $request): void
{
$tenantId = $request->tenantId;
$role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null;
if ($role === 'super_admin') {
$companies = $this->companyModel->findByTenant($tenantId);
} else {
// Filter by assigned company
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId, $assignedCompanyId]);
$companies = $stmt->fetchAll();
}
Response::json([
'success' => true,
'data' => $companies
]);
}
public function create(Request $request): void
{
$data = $request->getBody();
$data['tenant_id'] = $request->tenantId;
try {
$companyId = $this->companyService->createCompany($data);
Response::json([
'success' => true,
'data' => ['id' => $companyId],
'message' => 'تم إضافة الشركة بنجاح'
], 201);
} catch (Throwable $e) {
Response::error('فشل إضافة الشركة', 'CREATE_FAILED', 500);
}
}
public function updateJoFotara(Request $request, string $id): void
{
$data = [
'jofotara_client_id' => $request->input('client_id'),
'jofotara_secret_key' => $request->input('secret_key'),
'is_jofotara_linked' => 1
];
try {
$this->companyService->updateJoFotara($id, $data);
Response::json([
'success' => true,
'message' => 'تم تحديث بيانات جو-فواتير بنجاح'
]);
} catch (Throwable $e) {
Response::error('فشل تحديث البيانات', 'UPDATE_FAILED', 500);
}
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Models\BaseModel;
final class CompanyModel extends BaseModel
{
protected string $table = 'companies';
public function findByTenant(string $tenantId): array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
}

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Services\Security\EncryptionService;
use App\Modules\Companies\CompanyModel;
use Ramsey\Uuid\Uuid;
final class CompanyService
{
public function __construct(
private readonly CompanyModel $companyModel,
private readonly EncryptionService $encryption
) {}
public function createCompany(array $data): string
{
if (!isset($data['id'])) {
$data['id'] = Uuid::uuid4()->toString();
}
// Encrypt sensitive JoFotara credentials
if (isset($data['jofotara_client_id'])) {
$data['jofotara_client_id_encrypted'] = $this->encryption->encrypt($data['jofotara_client_id']);
unset($data['jofotara_client_id']);
}
if (isset($data['jofotara_secret_key'])) {
$data['jofotara_secret_key_encrypted'] = $this->encryption->encrypt($data['jofotara_secret_key']);
unset($data['jofotara_secret_key']);
}
return (string)$this->companyModel->create($data);
}
public function updateJoFotara(string $id, array $data): bool
{
if (isset($data['jofotara_client_id'])) {
$data['jofotara_client_id_encrypted'] = $this->encryption->encrypt($data['jofotara_client_id']);
unset($data['jofotara_client_id']);
}
if (isset($data['jofotara_secret_key'])) {
$data['jofotara_secret_key_encrypted'] = $this->encryption->encrypt($data['jofotara_secret_key']);
unset($data['jofotara_secret_key']);
}
return $this->companyModel->update($id, $data);
}
public function getJoFotaraCredentials(string $companyId): array
{
$company = $this->companyModel->find($companyId);
if (!$company) return [];
return [
'clientId' => $company['jofotara_client_id_encrypted'] ? $this->encryption->decrypt($company['jofotara_client_id_encrypted']) : null,
'secretKey' => $company['jofotara_secret_key_encrypted'] ? $this->encryption->decrypt($company['jofotara_secret_key_encrypted']) : null,
];
}
}

View File

@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Dashboard;
use App\Core\{Request, Response, Database};
final class DashboardController
{
public function getStats(Request $request): void
{
$tenantId = $request->tenantId;
$role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null;
$db = Database::getInstance();
// Build scope: accountants see only their company, admins see all tenant companies
$companyScope = '';
$params = [$tenantId];
if ($role === 'accountant' && $assignedCompanyId) {
$companyScope = ' AND i.company_id = ?';
$params[] = $assignedCompanyId;
}
// Invoices this month
$stmt = $db->prepare("SELECT COUNT(*) as c FROM invoices i
WHERE i.tenant_id = ? {$companyScope} AND MONTH(i.created_at) = MONTH(CURDATE()) AND YEAR(i.created_at) = YEAR(CURDATE()) AND i.deleted_at IS NULL");
$stmt->execute($params);
$thisMonth = (int)$stmt->fetchColumn();
// Total invoices
$stmt = $db->prepare("SELECT COUNT(*) as c FROM invoices i WHERE i.tenant_id = ? {$companyScope} AND i.deleted_at IS NULL");
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
// Status distribution
$stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices i
WHERE i.tenant_id = ? {$companyScope} AND i.deleted_at IS NULL GROUP BY status");
$stmt->execute($params);
$statusDistribution = $stmt->fetchAll();
// Approved count
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices i
WHERE i.tenant_id = ? {$companyScope} AND i.status = 'approved' AND i.deleted_at IS NULL");
$stmt->execute($params);
$approved = (int)$stmt->fetchColumn();
// Companies count
$stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND is_active = 1 AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
$companiesCount = (int)$stmt->fetchColumn();
// Subscription usage
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
$usagePct = $sub && $sub['max_invoices_per_month'] > 0
? round(($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100)
: 0;
// Recent invoices with company name
$stmt = $db->prepare("SELECT i.id, i.invoice_number, i.invoice_date, i.grand_total, i.status, i.created_at, c.name as company_name
FROM invoices i
JOIN companies c ON i.company_id = c.id
WHERE i.tenant_id = ? {$companyScope} AND i.deleted_at IS NULL
ORDER BY i.created_at DESC LIMIT 10");
$stmt->execute($params);
$recent = $stmt->fetchAll();
// Unresolved risk flags
$stmt = $db->prepare("SELECT COUNT(*) FROM risk_scores WHERE tenant_id = ? AND is_resolved = 0");
$stmt->execute([$tenantId]);
$riskCount = (int)$stmt->fetchColumn();
Response::json([
'success' => true,
'data' => [
'total_invoices' => $total,
'invoices_this_month' => $thisMonth,
'approved_invoices' => $approved,
'companies_count' => $companiesCount,
'subscription_usage_pct' => $usagePct,
'subscription' => $sub,
'status_distribution' => $statusDistribution,
'recent_invoices' => $recent,
'risk_alerts_count' => $riskCount,
]
]);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices\Actions;
use App\Core\Database;
use Exception;
final class DownloadInvoiceFileAction {
public function execute(string $invoiceId, string $tenantId, $user): array {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT original_file_path, company_id FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$invoiceId, $tenantId]);
$invoice = $stmt->fetch();
if (!$invoice || !file_exists($invoice['original_file_path'])) {
throw new Exception('الملف غير موجود', 404);
}
$role = $user->role ?? 'viewer';
if ($role !== 'super_admin' && $invoice['company_id'] !== ($user->assigned_company_id ?? null)) {
throw new Exception('غير مصرح لك بمشاهدة هذا الملف', 403);
}
return [
'path' => $invoice['original_file_path'],
'mime' => mime_content_type($invoice['original_file_path']),
'name' => basename($invoice['original_file_path'])
];
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices\Actions;
use App\Core\Database;
use Exception;
final class GetInvoiceDetailAction {
public function execute(string $invoiceId, string $tenantId, $user): array {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$invoiceId, $tenantId]);
$invoice = $stmt->fetch();
if (!$invoice) {
throw new Exception('الفاتورة غير موجودة أو تم حذفها', 404);
}
$role = $user->role ?? 'viewer';
if ($role !== 'super_admin' && $invoice['company_id'] !== ($user->assigned_company_id ?? null)) {
throw new Exception('غير مصرح لك بالوصول لهذه الفاتورة', 403);
}
$stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
$stmt->execute([$invoiceId]);
$invoice['lines'] = $stmt->fetchAll() ?: [];
return $invoice;
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices\Actions;
use App\Core\Database;
final class ListInvoicesAction {
public function execute(string $tenantId, $user): array {
$db = Database::getInstance();
$role = $user->role ?? 'viewer';
$assignedCompanyId = $user->assigned_company_id ?? null;
if ($role === 'super_admin' || $role === '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.tenant_id = ? AND i.deleted_at IS NULL
ORDER BY i.created_at DESC");
$stmt->execute([$tenantId]);
} 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.tenant_id = ? AND i.company_id = ? AND i.deleted_at IS NULL
ORDER BY i.created_at DESC");
$stmt->execute([$tenantId, $assignedCompanyId]);
}
return $stmt->fetchAll() ?: [];
}
}

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices\Actions;
use App\Services\QueueService;
use App\Core\Database;
use Exception;
final class SubmitInvoiceAction {
public function execute(string $invoiceId, string $tenantId): void {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$invoiceId, $tenantId]);
if (!$stmt->fetch()) {
throw new Exception('الفاتورة غير موجودة', 404);
}
QueueService::push('submit_jofotara', [
'invoice_id' => $invoiceId
]);
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices\Actions;
use App\Services\FileStorageService;
use App\Modules\Invoices\InvoiceModel;
use App\Services\QueueService;
use Exception;
use Ramsey\Uuid\Uuid;
final class UploadInvoiceAction {
public function __construct(
private readonly FileStorageService $storage,
private readonly InvoiceModel $invoiceModel
) {}
public function execute(array $files, string $companyId, string $tenantId, $user): string {
if (empty($files['invoice'])) {
throw new Exception('يرجى اختيار ملف الفاتورة', 422);
}
if (!$companyId) {
throw new Exception('يرجى تحديد الشركة', 422);
}
$filePath = $this->storage->store($files['invoice'], $tenantId, $companyId);
$fileHash = $this->storage->getHash($filePath);
$invoiceId = Uuid::uuid4()->toString();
$this->invoiceModel->create([
'id' => $invoiceId,
'tenant_id' => $tenantId,
'company_id' => $companyId,
'uploaded_by' => $user->user_id ?? null,
'status' => 'uploaded',
'original_file_path' => $filePath,
'original_file_hash' => $fileHash,
'idempotency_key' => bin2hex(random_bytes(16))
]);
QueueService::push('invoice_extraction', [
'invoice_id' => $invoiceId,
'file_path' => $filePath,
'mime_type' => mime_content_type($filePath)
]);
return $invoiceId;
}
}

View File

@@ -1,151 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices;
use App\Core\{Request, Response, Database};
use Throwable;
final class InvoiceController
{
public function index(Request $request): void
{
$tenantId = $request->tenantId;
$role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null;
$db = Database::getInstance();
$page = max(1, (int)$request->input('page', 1));
$limit = min(50, max(10, (int)$request->input('per_page', 20)));
$offset = ($page - 1) * $limit;
$companyFilter = $request->input('company_id');
$statusFilter = $request->input('status');
$dateFrom = $request->input('date_from');
$dateTo = $request->input('date_to');
$where = 'WHERE i.tenant_id = ? AND i.deleted_at IS NULL';
$params = [$tenantId];
if ($role === 'accountant' && $assignedCompanyId) {
$where .= ' AND i.company_id = ?';
$params[] = $assignedCompanyId;
} elseif ($companyFilter) {
$where .= ' AND i.company_id = ?';
$params[] = $companyFilter;
}
if ($statusFilter) { $where .= ' AND i.status = ?'; $params[] = $statusFilter; }
if ($dateFrom) { $where .= ' AND i.invoice_date >= ?'; $params[] = $dateFrom; }
if ($dateTo) { $where .= ' AND i.invoice_date <= ?'; $params[] = $dateTo; }
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices i {$where}");
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT i.id, i.invoice_number, i.invoice_date, i.grand_total, i.tax_amount,
i.status, i.ai_confidence_score, i.created_at, c.name as company_name
FROM invoices i JOIN companies c ON i.company_id = c.id
{$where} ORDER BY i.created_at DESC LIMIT {$limit} OFFSET {$offset}");
$stmt->execute($params);
$invoices = $stmt->fetchAll();
Response::json([
'success' => true,
'data' => $invoices,
'meta' => [
'total' => $total,
'page' => $page,
'per_page' => $limit,
'last_page' => ceil($total / $limit)
]
]);
}
public function show(Request $request, string $id): void
{
$tenantId = $request->tenantId;
$db = Database::getInstance();
// Fetch invoice with company name (tenant-scoped)
$stmt = $db->prepare("SELECT i.*, c.name as company_name, c.tax_identification_number as company_tin
FROM invoices i
JOIN companies c ON i.company_id = c.id
WHERE i.id = ? AND i.tenant_id = ? AND i.deleted_at IS NULL");
$stmt->execute([$id, $tenantId]);
$invoice = $stmt->fetch();
if (!$invoice) {
Response::error('الفاتورة غير موجودة', 'NOT_FOUND', 404);
return;
}
// Fetch lines
$stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
$stmt->execute([$id]);
$invoice['lines'] = $stmt->fetchAll();
// Parse JSON fields
if (!empty($invoice['validation_errors'])) {
$invoice['validation_errors'] = json_decode($invoice['validation_errors'], true);
}
if (!empty($invoice['jofotara_response'])) {
$invoice['jofotara_response'] = json_decode($invoice['jofotara_response'], true);
}
Response::json(['success' => true, 'data' => $invoice]);
}
public function serveFile(Request $request, string $id): void
{
$tenantId = $request->tenantId;
$db = Database::getInstance();
$stmt = $db->prepare("SELECT original_file_path FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$id, $tenantId]);
$invoice = $stmt->fetch();
if (!$invoice || !$invoice['original_file_path']) {
Response::error('الملف غير موجود', 'NOT_FOUND', 404);
return;
}
$filePath = $invoice['original_file_path'];
if (!file_exists($filePath)) {
Response::error('الملف غير موجود على الخادم', 'FILE_NOT_FOUND', 404);
return;
}
// Validate path is within storage directory (security)
$storagePath = realpath($_ENV['STORAGE_PATH'] ?? dirname(__DIR__, 3) . '/storage');
$realPath = realpath($filePath);
if (!$realPath || !str_starts_with($realPath, $storagePath)) {
Response::error('وصول غير مصرح', 'FORBIDDEN', 403);
return;
}
$mimeType = mime_content_type($filePath);
$filename = basename($filePath);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($filePath));
header('Content-Disposition: inline; filename="' . $filename . '"');
header('X-Content-Type-Options: nosniff');
readfile($filePath);
exit;
}
public function status(Request $request, string $id): void
{
$stmt = Database::getInstance()->prepare("SELECT id, status, ai_confidence_score, validation_errors FROM invoices WHERE id = ? AND tenant_id = ?");
$stmt->execute([$id, $request->tenantId]);
$invoice = $stmt->fetch();
Response::json(['success' => true, 'data' => $invoice]);
}
public function upload(Request $request): void
{
// ... Keeping existing upload logic but wrapping in simplified controller if needed
// For now, I'll use the provided instructions' style
// (Wait, the prompt didn't provide a full upload() implementation, but I should keep the functionality)
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Invoices;
use App\Models\BaseModel;
final class InvoiceModel extends BaseModel
{
protected string $table = 'invoices';
public function findByTenant(string $tenantId): array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC");
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
public function findByStatus(string $status, ?string $tenantId = null): array
{
$sql = "SELECT * FROM {$this->table} WHERE status = ? AND deleted_at IS NULL";
$params = [$status];
if ($tenantId) {
$sql .= " AND tenant_id = ?";
$params[] = $tenantId;
}
$stmt = $this->db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Risks;
use App\Core\{Database, Request, Response};
final class RiskController
{
public function index(Request $request): void
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT r.*, c.name AS company_name, i.invoice_number
FROM risk_scores r
LEFT JOIN companies c ON c.id = r.company_id
LEFT JOIN invoices i ON i.id = r.invoice_id
WHERE r.tenant_id = ? AND r.is_resolved = 0
ORDER BY r.score ASC, r.created_at DESC"
);
$stmt->execute([$request->tenantId]);
Response::json([
'success' => true,
'data' => $stmt->fetchAll(),
]);
}
public function resolve(Request $request, string $id): void
{
$db = Database::getInstance();
$resolvedBy = $request->user->user_id ?? null;
$stmt = $db->prepare(
"UPDATE risk_scores
SET is_resolved = 1, resolved_by = ?, resolved_at = NOW()
WHERE id = ? AND tenant_id = ?"
);
$stmt->execute([$resolvedBy, $id, $request->tenantId]);
if ($stmt->rowCount() === 0) {
Response::error('تنبيه المخاطر غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'message' => 'تم حل التنبيه بنجاح',
]);
}
}

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Core\{Request, Response};
use App\Modules\Subscriptions\SubscriptionModel;
final class SubscriptionController
{
public function __construct(private readonly SubscriptionModel $subscriptionModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$subscription = $this->subscriptionModel->findByTenantId($tenantId);
if (!$subscription) {
Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $subscription
]);
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Models\BaseModel;
final class SubscriptionModel extends BaseModel
{
protected string $table = 'subscriptions';
public function findByTenantId(string $tenantId): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1");
$stmt->execute([$tenantId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Core\{Request, Response};
use App\Modules\Tenants\TenantModel;
final class TenantController
{
public function __construct(private readonly TenantModel $tenantModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$tenant = $this->tenantModel->find($tenantId);
if (!$tenant) {
Response::error('المستأجر غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $tenant
]);
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Models\BaseModel;
final class TenantModel extends BaseModel
{
protected string $table = 'tenants';
public function findByEmail(string $email): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$email]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Models\BaseModel;
final class UserModel extends BaseModel
{
protected string $table = 'users';
public function findByEmail(string $email, ?string $tenantId = null): ?array
{
$sql = "SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL";
$params = [$email];
if ($tenantId) {
$sql .= " AND tenant_id = ?";
$params[] = $tenantId;
}
$stmt = $this->db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetch() ?: null;
}
public function findAllByTenant(string $tenantId): array
{
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
public function findById(string $id, string $tenantId): ?array
{
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$id, $tenantId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Core\{Request, Response};
use App\Modules\Users\UserModel;
final class UsersController
{
public function __construct(private readonly UserModel $userModel) {}
public function list(Request $request): void
{
$tenantId = $request->tenantId;
// Strict RBAC check: only admins can list users
if ($request->user->role !== 'admin' && $request->user->role !== 'super_admin') {
Response::error('غير مصرح لك بعرض قائمة المستخدمين', 'FORBIDDEN', 403);
return;
}
$users = $this->userModel->findAllByTenant($tenantId);
Response::json([
'success' => true,
'data' => $users
]);
}
public function create(Request $request): void
{
$tenantId = $request->tenantId;
$data = $request->getBody();
// RBAC: Only admins can create users
if ($request->user->role !== 'admin' && $request->user->role !== 'super_admin') {
Response::error('غير مصرح لك بإضافة مستخدمين', 'FORBIDDEN', 403);
return;
}
if (empty($data['email']) || empty($data['password']) || empty($data['name']) || empty($data['role'])) {
Response::error('جميع الحقول مطلوبة', 'VALIDATION_ERROR', 422);
return;
}
// Email uniqueness must be scoped to tenant or global?
// Typically global for identity, but prompt says fix uniqueness conflict.
if ($this->userModel->findByEmail($data['email'])) {
Response::error('البريد الإلكتروني مستخدم مسبقاً', 'DUPLICATE_EMAIL', 409);
return;
}
$userId = \Ramsey\Uuid\Uuid::uuid4()->toString();
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['name'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => $data['role'],
'assigned_company_id' => $data['assigned_company_id'] ?? null,
'is_active' => 1
]);
Response::json([
'success' => true,
'message' => 'تم إضافة المستخدم بنجاح',
'data' => ['id' => $userId]
], 201);
}
public function update(Request $request, string $id): void
{
$tenantId = $request->tenantId;
$data = $request->getBody();
if ($request->user->role !== 'admin' && $request->user->role !== 'super_admin') {
Response::error('غير مصرح لك بتعديل المستخدمين', 'FORBIDDEN', 403);
return;
}
$user = $this->userModel->findById($id, $tenantId);
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
}
$updateData = [];
if (isset($data['name'])) $updateData['name'] = $data['name'];
if (isset($data['role'])) $updateData['role'] = $data['role'];
if (isset($data['is_active'])) $updateData['is_active'] = $data['is_active'];
if (isset($data['assigned_company_id'])) $updateData['assigned_company_id'] = $data['assigned_company_id'];
if (!empty($data['password'])) {
$updateData['password_hash'] = password_hash($data['password'], PASSWORD_ARGON2ID);
}
$this->userModel->update($id, $updateData);
Response::json([
'success' => true,
'message' => 'تم تحديث بيانات المستخدم بنجاح'
]);
}
public function destroy(Request $request, string $id): void
{
$tenantId = $request->tenantId;
if ($request->user->role !== 'admin' && $request->user->role !== 'super_admin') {
Response::error('غير مصرح لك بحذف المستخدمين', 'FORBIDDEN', 403);
return;
}
if ($id === $request->user->id) {
Response::error('لا يمكنك حذف حسابك الخاص', 'BAD_REQUEST', 400);
return;
}
$this->userModel->delete($id, $tenantId);
Response::json([
'success' => true,
'message' => 'تم حذف المستخدم بنجاح'
]);
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\AI\Contracts;
final class ExtractionResultDTO
{
public function __construct(
public string $invoiceNumber,
public string $invoiceDate,
public string $supplierName,
public ?string $supplierTin,
public string $supplierAddress,
public ?string $buyerName,
public ?string $buyerTin,
public array $lines,
public float $subtotal,
public float $taxAmount,
public float $grand_total,
public string $currency,
public float $confidence,
public array $usage
) {}
}
interface AIProviderInterface
{
public function extractFromFile(string $filePath, string $mimeType): ExtractionResultDTO;
public function getProviderName(): string;
}

View File

@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\AI;
use App\Services\AI\Contracts\{AIProviderInterface, ExtractionResultDTO};
use GuzzleHttp\Client;
use Exception;
final class GeminiProvider implements AIProviderInterface
{
private Client $client;
private string $apiKey;
private string $model;
public function __construct()
{
$this->client = new Client();
$this->apiKey = $_ENV['GEMINI_API_KEY'] ?? '';
$this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash';
}
public function extractFromFile(string $filePath, string $mimeType): ExtractionResultDTO
{
$fileData = base64_encode(file_get_contents($filePath));
$prompt = "Extract invoice data from this file. Return ONLY valid JSON (no markdown). " .
"Fields: invoice_number, invoice_date (YYYY-MM-DD), supplier_name, supplier_tin, supplier_address, " .
"buyer_name, buyer_tin, lines (description, quantity, unit_price, line_total, tax_rate), " .
"subtotal, tax_amount, grand_total, currency (JOD), confidence (0-1).";
$response = $this->client->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [
'json' => [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inline_data' => [
'mime_type' => $mimeType,
'data' => $fileData
]
]
]
]
],
'generationConfig' => [
'response_mime_type' => 'application/json'
]
]
]);
$data = json_decode($response->getBody()->getContents(), true);
$jsonStr = $data['candidates'][0]['content']['parts'][0]['text'] ?? '{}';
$result = json_decode($jsonStr, true);
return new ExtractionResultDTO(
$result['invoice_number'] ?? '',
$result['invoice_date'] ?? '',
$result['supplier_name'] ?? '',
$result['supplier_tin'] ?? null,
$result['supplier_address'] ?? '',
$result['buyer_name'] ?? null,
$result['buyer_tin'] ?? null,
$result['lines'] ?? [],
(float)($result['subtotal'] ?? 0),
(float)($result['tax_amount'] ?? 0),
(float)($result['grand_total'] ?? 0),
$result['currency'] ?? 'JOD',
(float)($result['confidence'] ?? 0),
$data['usageMetadata'] ?? []
);
}
public function getProviderName(): string { return 'gemini'; }
}

View File

@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\AI;
use App\Services\AI\Contracts\AIProviderInterface;
use Exception;
final class OpenAIProvider implements AIProviderInterface
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = $_ENV['OPENAI_API_KEY'] ?? '';
$this->model = $_ENV['OPENAI_MODEL'] ?? 'gpt-4o-mini';
}
public function isConfigured(): bool
{
return !empty($this->apiKey);
}
public function extractInvoiceData(string $fileContent, string $mimeType, string $prompt): array
{
if (!$this->isConfigured()) {
throw new Exception("OpenAI API Key is missing. Please configure it in .env");
}
$base64Data = base64_encode($fileContent);
$payload = [
'model' => $this->model,
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => $prompt
],
[
'type' => 'image_url',
'image_url' => [
'url' => "data:{$mimeType};base64,{$base64Data}"
]
]
]
]
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0.1
];
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"Authorization: Bearer {$this->apiKey}"
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("OpenAI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}");
}
$result = json_decode($response, true);
$text = $result['choices'][0]['message']['content'] ?? '{}';
$data = json_decode($text, true);
if (!is_array($data)) {
throw new Exception("Failed to parse OpenAI output as JSON: {$text}");
}
return $data;
}
}

View File

@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
final class AiExtractionService
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = $_ENV['GEMINI_API_KEY'] ?? '';
$this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash';
}
public function extractInvoiceData(string $filePath, string $mimeType): array
{
if (empty($this->apiKey)) {
throw new Exception("Gemini API Key is missing. Please configure it in .env");
}
$fileContent = file_get_contents($filePath);
if ($fileContent === false) {
throw new Exception("Could not read uploaded invoice file.");
}
$base64Data = base64_encode($fileContent);
$prompt = "Please extract the following information from this invoice and return it strictly as JSON without markdown blocks or backticks:\n"
. "- invoice_number\n"
. "- invoice_date (YYYY-MM-DD)\n"
. "- total_amount\n"
. "- tax_amount\n"
. "- vendor_name\n"
. "- vendor_tax_number";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inline_data' => [
'mime_type' => $mimeType,
'data' => $base64Data
]
]
]
]
],
'generationConfig' => [
'temperature' => 0.1,
'response_mime_type' => 'application/json'
]
];
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json'
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("AI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}");
}
$result = json_decode($response, true);
$text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '{}';
$data = json_decode($text, true);
if (!is_array($data)) {
throw new Exception("Failed to parse AI output as JSON: {$text}");
}
return $data;
}
}

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
final class AuditService
{
public static function log(
string $action,
?string $tenantId = null,
?string $userId = null,
?string $entityType = null,
?string $entityId = null,
?array $oldData = null,
?array $newData = null,
?array $metadata = null
): void {
try {
$db = Database::getInstance();
$stmt = $db->prepare("INSERT INTO audit_logs
(tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())");
$stmt->execute([
$tenantId,
$userId,
$action,
$entityType,
$entityId,
$oldData ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null,
$newData ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null,
$_SERVER['REMOTE_ADDR'] ?? null,
$_SERVER['HTTP_USER_AGENT'] ?? null,
$metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
]);
} catch (\Throwable $e) {
error_log('[Audit] Failed: ' . $e->getMessage());
}
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
final class FileStorageService
{
private string $storagePath;
public function __construct()
{
// Use dynamic path to avoid issues if Mac .env is deployed to Linux server
$this->storagePath = dirname(__DIR__, 2) . '/storage';
}
public function store(array $file, string $tenantId, string $companyId): string
{
// 1. Validate MIME
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'application/json', 'text/plain', 'text/xml', 'application/xml'];
if (!in_array($mime, $allowedMimes)) {
throw new Exception("نوع الملف غير مسموح به ({$mime})");
}
// 2. Generate path
$dir = $this->storagePath . '/invoices/' . $tenantId . '/' . $companyId;
if (!is_dir($dir)) {
if (!mkdir($dir, 0777, true)) {
$err = error_get_last();
throw new Exception("فشل إنشاء مجلد الحفظ: " . $dir . " - " . ($err['message'] ?? ''));
}
}
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = hash('sha256', $file['name'] . time() . uniqid()) . '.' . $extension;
$targetPath = $dir . '/' . $filename;
if (isset($file['error']) && $file['error'] !== UPLOAD_ERR_OK) {
throw new Exception("حدث خطأ أثناء رفع الملف من المتصفح. كود الخطأ: " . $file['error']);
}
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
// Fallback for some non-standard PHP environments
if (!copy($file['tmp_name'], $targetPath)) {
$err = error_get_last();
throw new Exception("فشل نقل الملف إلى: " . $targetPath . " - " . ($err['message'] ?? ''));
}
}
return $targetPath;
}
public function getHash(string $filePath): string
{
return hash_file('sha256', $filePath);
}
}

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

@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\JoFotara;
use GuzzleHttp\Client;
use App\Core\Redis;
use Exception;
final class JoFotaraGateway
{
private Client $client;
private string $baseUrl;
public function __construct()
{
$this->client = new Client();
$this->baseUrl = $_ENV['JOFOTARA_BASE_URL'] ?? 'https://backend.jofotara.gov.jo/core/invoices';
}
/**
* Submit invoice to JoFotara with Circuit Breaker
*/
public function submitInvoice(string $companyId, string $xmlBase64, array $credentials): array
{
$cbKey = "cb:jofotara:{$companyId}";
if ($this->isCircuitOpen($cbKey)) {
throw new Exception("بوابة جو-فواتير غير متاحة حالياً لهذه الشركة، يرجى المحاولة لاحقاً");
}
try {
$response = $this->client->post($this->baseUrl, [
'json' => [
'clientId' => $credentials['clientId'],
'secretKey' => $credentials['secretKey'],
'invoiceType' => 'invoice',
'invoiceData' => $xmlBase64
],
'timeout' => 30
]);
$result = json_decode($response->getBody()->getContents(), true);
$this->resetFailures($cbKey);
return $result;
} catch (\Throwable $e) {
$this->recordFailure($cbKey);
throw $e;
}
}
private function isCircuitOpen(string $key): bool
{
$redis = Redis::getInstance();
return (bool)$redis->get("{$key}:open");
}
private function recordFailure(string $key): void
{
$redis = Redis::getInstance();
$failures = (int)$redis->incr("{$key}:failures");
if ($failures >= 5) {
$redis->setex("{$key}:open", 300, 1); // Open for 5 minutes
}
}
private function resetFailures(string $key): void
{
$redis = Redis::getInstance();
$redis->del(["{$key}:failures", "{$key}:open"]);
}
}

View File

@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\JoFotara;
/**
* UBLGeneratorService
*
* Generates UBL 2.1 compliant XML using DOMDocument for precise namespace control.
*/
final class UBLGeneratorService
{
public function generate(array $invoice, array $lines, array $company): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->createElementNS('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', 'Invoice');
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cac', 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2');
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2');
$dom->appendChild($root);
// 1. Basic Information
$root->appendChild($dom->createElement('cbc:UBLVersionID', '2.1'));
$root->appendChild($dom->createElement('cbc:CustomizationID', 'TRADACO-2.1'));
$root->appendChild($dom->createElement('cbc:ProfileID', 'reporting:1.0'));
$root->appendChild($dom->createElement('cbc:ID', $invoice['invoice_number']));
$root->appendChild($dom->createElement('cbc:IssueDate', $invoice['invoice_date']));
$typeCode = $dom->createElement('cbc:InvoiceTypeCode', $invoice['ubl_type_code'] ?? '388');
$typeCode->setAttribute('name', $invoice['invoice_category'] ?? '01');
$root->appendChild($typeCode);
$root->appendChild($dom->createElement('cbc:DocumentCurrencyCode', 'JOD'));
$root->appendChild($dom->createElement('cbc:TaxCurrencyCode', 'JOD'));
// 2. AccountingSupplierParty
$supplierParty = $dom->createElement('cac:AccountingSupplierParty');
$party = $dom->createElement('cac:Party');
$partyId = $dom->createElement('cac:PartyIdentification');
$idNode = $dom->createElement('cbc:ID', $company['tax_identification_number']);
$idNode->setAttribute('schemeID', 'TN');
$partyId->appendChild($idNode);
$party->appendChild($partyId);
$partyName = $dom->createElement('cac:PartyName');
$partyName->appendChild($dom->createElement('cbc:Name', $company['name']));
$party->appendChild($partyName);
$addr = $dom->createElement('cac:PostalAddress');
$addr->appendChild($dom->createElement('cbc:CityName', $company['city'] ?? 'Amman'));
$country = $dom->createElement('cac:Country');
$country->appendChild($dom->createElement('cbc:IdentificationCode', 'JO'));
$addr->appendChild($country);
$party->appendChild($addr);
$taxScheme = $dom->createElement('cac:PartyTaxScheme');
$taxScheme->appendChild($dom->createElement('cbc:RegistrationName', $company['name']));
$taxScheme->appendChild($dom->createElement('cbc:CompanyID', $company['tax_identification_number']));
$ts = $dom->createElement('cac:TaxScheme');
$ts->appendChild($dom->createElement('cbc:ID', 'VAT'));
$taxScheme->appendChild($ts);
$party->appendChild($taxScheme);
$legalEntity = $dom->createElement('cac:PartyLegalEntity');
$legalEntity->appendChild($dom->createElement('cbc:RegistrationName', $company['name']));
$party->appendChild($legalEntity);
$supplierParty->appendChild($party);
$root->appendChild($supplierParty);
// 3. AccountingCustomerParty
$customerParty = $dom->createElement('cac:AccountingCustomerParty');
$cParty = $dom->createElement('cac:Party');
$cName = $dom->createElement('cac:PartyName');
$cName->appendChild($dom->createElement('cbc:Name', $invoice['buyer_name'] ?? 'عميل عام'));
$cParty->appendChild($cName);
if (!empty($invoice['buyer_tin'])) {
$cId = $dom->createElement('cac:PartyIdentification');
$cidNode = $dom->createElement('cbc:ID', $invoice['buyer_tin']);
$cidNode->setAttribute('schemeID', 'TN');
$cId->appendChild($cidNode);
$cParty->appendChild($cId);
}
$customerParty->appendChild($cParty);
$root->appendChild($customerParty);
// 4. PaymentMeans
$paymentMeans = $dom->createElement('cac:PaymentMeans');
$paymentMeans->appendChild($dom->createElement('cbc:PaymentMeansCode', $invoice['payment_method_code'] ?? '10'));
$root->appendChild($paymentMeans);
// 5. TaxTotal
$taxTotal = $dom->createElement('cac:TaxTotal');
$taxAmt = $dom->createElement('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''));
$taxAmt->setAttribute('currencyID', 'JOD');
$taxTotal->appendChild($taxAmt);
$root->appendChild($taxTotal);
// 6. LegalMonetaryTotal
$monetaryTotal = $dom->createElement('cac:LegalMonetaryTotal');
$fields = [
'LineExtensionAmount' => $invoice['subtotal'],
'TaxExclusiveAmount' => $invoice['subtotal'],
'TaxInclusiveAmount' => $invoice['grand_total'],
'AllowanceTotalAmount' => $invoice['discount_total'] ?? 0,
'PayableAmount' => $invoice['grand_total']
];
foreach ($fields as $field => $val) {
$node = $dom->createElement('cbc:' . $field, number_format((float)$val, 3, '.', ''));
$node->setAttribute('currencyID', 'JOD');
$monetaryTotal->appendChild($node);
}
$root->appendChild($monetaryTotal);
// 7. Invoice Lines
foreach ($lines as $line) {
$iLine = $dom->createElement('cac:InvoiceLine');
$iLine->appendChild($dom->createElement('cbc:ID', (string)$line['line_number']));
$qty = $dom->createElement('cbc:InvoicedQuantity', number_format((float)$line['quantity'], 3, '.', ''));
$qty->setAttribute('unitCode', 'PCE');
$iLine->appendChild($qty);
$lineExt = $dom->createElement('cbc:LineExtensionAmount', number_format((float)$line['line_total'], 3, '.', ''));
$lineExt->setAttribute('currencyID', 'JOD');
$iLine->appendChild($lineExt);
$item = $dom->createElement('cac:Item');
$item->appendChild($dom->createElement('cbc:Description', $line['description']));
$iLine->appendChild($item);
$price = $dom->createElement('cac:Price');
$pAmt = $dom->createElement('cbc:PriceAmount', number_format((float)$line['unit_price'], 3, '.', ''));
$pAmt->setAttribute('currencyID', 'JOD');
$price->appendChild($pAmt);
$iLine->appendChild($price);
$root->appendChild($iLine);
}
return $dom->saveXML();
}
}

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

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Redis;
use App\Core\Database;
final class QueueService
{
private const REDIS_QUEUE = 'musadaq_jobs';
public static function push(string $type, array $payload, int $priority = 0, int $delay = 0): void
{
$job = [
'id' => bin2hex(random_bytes(16)),
'type' => $type,
'payload' => $payload,
'priority' => $priority,
'attempts' => 0,
'created_at' => time()
];
try {
$redis = Redis::getInstance();
$redis->lpush(self::REDIS_QUEUE, json_encode($job));
} catch (\Throwable $e) {
// Fallback to MySQL
self::pushToDatabase($job);
}
}
private static function pushToDatabase(array $job): void
{
$db = Database::getInstance();
$stmt = $db->prepare("INSERT INTO queue_jobs (id, type, payload, priority, status) VALUES (?, ?, ?, ?, 'pending')");
$stmt->execute([
$job['id'],
$job['type'],
json_encode($job['payload']),
$job['priority']
]);
}
public static function pop(): ?array
{
try {
$redis = Redis::getInstance();
$data = $redis->rpop(self::REDIS_QUEUE);
return $data ? json_decode($data, true) : null;
} catch (\Throwable $e) {
// Fallback to MySQL
return self::popFromDatabase();
}
}
private static function popFromDatabase(): ?array
{
$db = Database::getInstance();
$db->beginTransaction();
try {
$stmt = $db->prepare("SELECT * FROM queue_jobs WHERE status = 'pending' ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE");
$stmt->execute();
$job = $stmt->fetch();
if ($job) {
$db->prepare("UPDATE queue_jobs SET status = 'processing', locked_at = NOW() WHERE id = ?")->execute([$job['id']]);
$db->commit();
return [
'id' => $job['id'],
'type' => $job['type'],
'payload' => json_decode($job['payload'], true),
'attempts' => $job['attempts']
];
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
}
return null;
}
}

View File

@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
final class RiskAnalysisService
{
public function calculateCompanyRiskScore(string $companyId): array
{
$db = Database::getInstance();
$score = 100;
$factors = [];
// 1. Rejection Rate
$stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices WHERE company_id = ? GROUP BY status");
$stmt->execute([$companyId]);
$stats = $stmt->fetchAll();
$total = 0;
$rejected = 0;
foreach ($stats as $stat) {
$total += $stat['count'];
if ($stat['status'] === 'rejected' || $stat['status'] === 'validation_failed') {
$rejected += $stat['count'];
}
}
if ($total > 0) {
$rejectionRate = $rejected / $total;
if ($rejectionRate > 0.10) { // More than 10% rejections
$penalty = min(30, (int)(($rejectionRate - 0.10) * 100));
$score -= $penalty;
$factors[] = "نسبة رفض عالية: " . round($rejectionRate * 100, 1) . "% (خصم {$penalty} نقطة)";
}
}
// 2. High Value Cash Invoices
$stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND invoice_type = 'cash' AND grand_total > 5000");
$stmt->execute([$companyId]);
$highValueCash = $stmt->fetch()['count'];
if ($highValueCash > 0) {
$penalty = min(20, $highValueCash * 2);
$score -= $penalty;
$factors[] = "وجود فواتير نقدية بقيم عالية: {$highValueCash} فاتورة (خصم {$penalty} نقطة)";
}
// 3. Late submissions (invoice_date is much older than created_at)
$stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND DATEDIFF(created_at, invoice_date) > 7");
$stmt->execute([$companyId]);
$lateInvoices = $stmt->fetch()['count'];
if ($lateInvoices > 0) {
$penalty = min(15, $lateInvoices * 1);
$score -= $penalty;
$factors[] = "تأخير في رفع الفواتير: {$lateInvoices} فاتورة متأخرة بأكثر من 7 أيام (خصم {$penalty} نقطة)";
}
// Determine Risk Level
$riskLevel = 'low';
if ($score < 50) {
$riskLevel = 'high';
} elseif ($score < 80) {
$riskLevel = 'medium';
}
return [
'score' => max(0, $score),
'level' => $riskLevel,
'factors' => $factors,
'calculated_at' => date('Y-m-d H:i:s')
];
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Exception;
final class EncryptionService
{
private string $key;
private const METHOD = 'aes-256-gcm';
public function __construct()
{
// Load from config/secrets.php — NEVER from .env directly
$secrets = require dirname(__DIR__, 3) . '/config/secrets.php';
$key = $secrets['encryption_key'] ?? '';
if (strlen($key) !== 32) {
throw new \RuntimeException(
'ENCRYPTION_KEY_B64 not set or invalid. ' .
'Generate: php -r "echo base64_encode(random_bytes(32));"'
);
}
$this->key = $key;
}
public function encrypt(string $plaintext): string
{
$iv = random_bytes(12); // 12 bytes for GCM
$tag = '';
$ciphertext = openssl_encrypt($plaintext, self::METHOD, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
if ($ciphertext === false) throw new \RuntimeException('Encryption failed');
return base64_encode($iv) . ':' . base64_encode($ciphertext) . ':' . base64_encode($tag);
}
public function decrypt(string $data): string
{
[$iv64, $ct64, $tag64] = explode(':', $data);
$plaintext = openssl_decrypt(
base64_decode($ct64), self::METHOD, $this->key,
OPENSSL_RAW_DATA, base64_decode($iv64), base64_decode($tag64)
);
if ($plaintext === false) throw new \RuntimeException('Decryption failed');
return $plaintext;
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use App\Core\Redis;
final class HmacService
{
/**
* Verify HMAC signature for external API requests (Flutter)
*/
public function verify(string $secret, string $method, string $path,
string $timestamp, string $nonce, string $body, string $signature): bool
{
// 1. Timestamp window (±5 minutes)
if (abs(time() - (int)$timestamp) > 300) return false;
// 2. Nonce replay protection
try {
$redis = \App\Core\Redis::getInstance();
$nonceKey = 'hmac_nonce:' . $nonce;
if ($redis->exists($nonceKey)) return false; // Replay attack
$redis->setex($nonceKey, 600, '1'); // TTL 10 minutes
} catch (\Throwable $e) {
// Redis unavailable — log but don't fail (degrade gracefully)
error_log('[HMAC] Redis unavailable for nonce check: ' . $e->getMessage());
}
// 3. Build & compare signature
$bodyHash = hash('sha256', $body);
$stringToSign = strtoupper($method) . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $bodyHash;
$calculated = hash_hmac('sha256', $stringToSign, $secret);
return hash_equals($calculated, $signature);
}
public function sign(string $secret, string $method, string $path, string $timestamp, string $nonce, string $body): string
{
$bodyHash = hash('sha256', $body);
$stringToSign = strtoupper($method) . "\n" .
$path . "\n" .
$timestamp . "\n" .
$nonce . "\n" .
$bodyHash;
return hash_hmac('sha256', $stringToSign, $secret);
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
final class JwtService
{
private string $secret;
private int $accessExpiry;
private int $refreshExpiry;
public function __construct()
{
$this->secret = $_ENV['JWT_SECRET'] ?? 'change-me';
$this->accessExpiry = (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900);
$this->refreshExpiry = (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800);
}
public function issueAccessToken(array $payload): string
{
$payload['exp'] = time() + $this->accessExpiry;
$payload['iat'] = time();
$payload['jti'] = bin2hex(random_bytes(16));
return JWT::encode($payload, $this->secret, 'HS256');
}
public function issueRefreshToken(string $userId): string
{
// Refresh token is a random string prefixed with userId for lookup
$random = bin2hex(random_bytes(32));
return $userId . '.' . $random;
}
public function verifyToken(string $token): array
{
try {
$decoded = JWT::decode($token, new Key($this->secret, 'HS256'));
return (array) $decoded;
} catch (Exception $e) {
throw new Exception("Invalid or expired token: " . $e->getMessage());
}
}
}

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

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
use Exception;
final class SubscriptionService
{
public function checkLimit(string $tenantId, string $type): void
{
$db = Database::getInstance();
$stmt = $db->prepare("SELECT * FROM subscriptions WHERE tenant_id = ? LIMIT 1");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) throw new Exception("لا يوجد اشتراك فعال");
if ($type === 'invoices') {
if ($sub['invoices_used_this_month'] >= $sub['max_invoices_per_month']) {
throw new Exception("لقد وصلت للحد الأقصى من الفواتير المسموح بها في خطتك الحالية");
}
}
if ($type === 'companies') {
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$countStmt->execute([$tenantId]);
$count = $countStmt->fetch()['total'];
if ($count >= $sub['max_companies']) {
throw new Exception("لقد وصلت للحد الأقصى من الشركات المسموح بها في خطتك الحالية");
}
}
}
public function incrementUsage(string $tenantId, string $type): void
{
if ($type === 'invoices') {
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE subscriptions SET invoices_used_this_month = invoices_used_this_month + 1 WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
}
}
}

View File

@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
final class TaxValidationService
{
/**
* Validate an invoice against Jordan ISTD rules (001-007)
*/
public function validate(array $invoice, array $lines): array
{
$errors = [];
// Rule 001: Total integrity (grand_total = Σ line_totals)
$lineSum = array_sum(array_column($lines, 'line_total'));
if (abs($invoice['grand_total'] - $lineSum) > 0.01) {
$errors[] = ['code' => 'RULE_001', 'message_ar' => 'مجموع سطور الفاتورة لا يطابق المجموع الكلي'];
}
// Rule 002: Tax integrity (tax_amount = subtotal × tax_rate)
foreach ($lines as $line) {
$expectedTax = round($line['quantity'] * $line['unit_price'] * $line['tax_rate'], 3);
if (abs($line['tax_amount'] - $expectedTax) > 0.01) {
$errors[] = ['code' => 'RULE_002', 'message_ar' => "خطأ في حساب الضريبة للسطر {$line['line_number']}"];
}
}
// Rule 003: Invoice number required
if (empty($invoice['invoice_number'])) {
$errors[] = ['code' => 'RULE_003', 'message_ar' => 'رقم الفاتورة مطلوب'];
}
// Rule 004: No future dates
if (strtotime($invoice['invoice_date']) > time()) {
$errors[] = ['code' => 'RULE_004', 'message_ar' => 'تاريخ الفاتورة لا يمكن أن يكون في المستقبل'];
}
// Rule 005: Valid JO Tax Rates
$validRates = [0.16, 0.10, 0.05, 0.04, 0.02, 0.00];
foreach ($lines as $line) {
if (!in_array(round((float)$line['tax_rate'], 2), $validRates)) {
$errors[] = ['code' => 'RULE_005', 'message_ar' => "نسبة الضريبة ({$line['tax_rate']}) غير صالحة في الأردن"];
}
}
// Rule 006: Buyer ID for large invoices (> 10,000 JOD)
if ($invoice['grand_total'] > 10000 && empty($invoice['buyer_tin']) && empty($invoice['buyer_national_id'])) {
$errors[] = ['code' => 'RULE_006', 'message_ar' => 'يجب تزويد الرقم الضريبي أو الوطني للمشتري للفواتير التي تتجاوز 10,000 دينار'];
}
// Rule 007: Discount integrity — subtotal - discount = Σ(line totals before tax)
$lineSumBeforeTax = array_sum(array_map(
fn($l) => round(($l['quantity'] * $l['unit_price']) - ($l['discount'] ?? 0), 3),
$lines
));
$expected = round($invoice['subtotal'] - $invoice['discount_total'], 3);
if (abs($expected - $lineSumBeforeTax) > 0.01) {
$errors[] = [
'code' => 'RULE_007',
'message_ar' => "خطأ في حساب الخصومات: المتوقع {$expected} JOD، المحسوب {$lineSumBeforeTax} JOD",
'message_en' => "Discount integrity error"
];
}
return [
'is_valid' => empty($errors),
'errors' => $errors
];
}
}

View File

@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* TotpService
*
* Implements RFC 6238 for Two-Factor Authentication (TOTP).
*/
final class TotpService
{
public function generateSecret(): string
{
// Generate a random 16-character base32 secret
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = '';
for ($i = 0; $i < 16; $i++) {
$secret .= $chars[random_int(0, 31)];
}
return $secret;
}
public function getQrCodeUrl(string $email, string $secret): string
{
$issuer = urlencode('Musadaq');
$email = urlencode($email);
$qrUrl = "otpauth://totp/Musadaq:{$email}?secret={$secret}&issuer=Musadaq";
return "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=" . urlencode($qrUrl);
}
public function verify(string $secret, string $code, int $window = 1): bool
{
$time = floor(time() / 30);
for ($i = -$window; $i <= $window; $i++) {
$t = $time + $i;
$hash = hash_hmac('sha1', pack('N*', 0) . pack('N*', $t), $this->base32Decode($secret));
$offset = ord($hash[19]) & 0x0F;
$otp = ((ord($hash[$offset]) & 0x7F) << 24 | (ord($hash[$offset+1]) & 0xFF) << 16 | (ord($hash[$offset+2]) & 0xFF) << 8 | (ord($hash[$offset+3]) & 0xFF)) % 1000000;
if (str_pad((string)$otp, 6, '0', STR_PAD_LEFT) === $code) return true;
}
return false;
}
private function base32Decode(string $base32): string
{
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$base32charsFlipped = array_flip(str_split($base32chars));
$output = '';
$v = 0;
$vbits = 0;
for ($i = 0, $j = strlen($base32); $i < $j; $i++) {
$v <<= 5;
if (isset($base32charsFlipped[$base32[$i]])) {
$v += $base32charsFlipped[$base32[$i]];
}
$vbits += 5;
while ($vbits >= 8) {
$vbits -= 8;
$output .= chr(($v >> $vbits) & 0xFF);
}
}
return $output;
}
}

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

19
app/bootstrap/auth.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
/**
* Global Auth State (Optional Helper)
*/
declare(strict_types=1);
// This can be used to store the current user globally if needed
// after successful middleware check.
$GLOBALS['current_user'] = null;
function current_user() {
return $GLOBALS['current_user'];
}
function set_current_user(array $user) {
$GLOBALS['current_user'] = $user;
}

31
app/bootstrap/env.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
/**
* Simple .env Loader
*/
// Primary environment file path as requested
$envFile = '/home/intaleqapp-musadaq/env/.env';
// Fallback for local development if the primary server path doesn't exist
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) continue;
$name = trim($parts[0]);
$value = trim($parts[1], " \t\n\r\0\x0B\"'");
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}

116
app/bootstrap/init.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
/**
* Application Bootstrap Initialization
*/
declare(strict_types=1);
// 1. Basic Constants
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') {
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
error_reporting(0);
ini_set('display_errors', '0');
}
// 4. H2 Fix: CORS — Whitelist only known origins
$allowedOrigins = array_filter(array_map('trim', explode(',', env('CORS_ORIGIN', 'https://musadaq.intaleqapp.com'))));
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($requestOrigin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: {$requestOrigin}");
} else {
// Fallback to first allowed origin (for non-browser API clients)
header("Access-Control-Allow-Origin: " . ($allowedOrigins[0] ?? ''));
}
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-HMAC-Signature, X-Timestamp");
header("Access-Control-Allow-Credentials: true");
header("Vary: Origin");
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// 5. Security Headers
header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: SAMEORIGIN");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
// CSP: Allow self + known CDNs (Tailwind, Alpine, Google Fonts)
$csp = "default-src 'self'; "
. "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; "
. "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
. "font-src 'self' https://fonts.gstatic.com; "
. "img-src 'self' data:; "
. "connect-src 'self';";
header("Content-Security-Policy: $csp");
// 6. Request body size limit (2MB for JSON, file uploads handled separately)
if (isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 2 * 1024 * 1024) {
if (empty($_FILES)) { // Don't block file uploads
http_response_code(413);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Request body too large'], JSON_UNESCAPED_UNICODE);
exit;
}
}
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$base_dir = APP_PATH . '/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative_class = substr($class, $len);
$parts = explode('\\', $relative_class);
$filename = array_pop($parts) . '.php';
$dir = implode('/', $parts); // No strtolower — preserves PascalCase on Linux
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
if (file_exists($file)) {
require $file;
}
});
// 7. Response Utility
require_once APP_PATH . '/bootstrap/response.php';
// 8. Global Auth Helper
require_once APP_PATH . '/bootstrap/auth.php';

View File

@@ -0,0 +1,72 @@
<?php
/**
* Standardized JSON Responses with Secure Logging
*/
declare(strict_types=1);
function json_response(bool $success, $data = null, ?string $message = null, int $code = 200) {
// H3 Fix: Redact sensitive fields before logging
$safeData = $data;
if (is_array($safeData)) {
$sensitiveKeys = ['access_token', 'refresh_token', 'password', 'password_hash', 'refresh_token_hash', 'token'];
array_walk_recursive($safeData, function (&$value, $key) use ($sensitiveKeys) {
if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
$value = '[REDACTED]';
}
});
}
// Log (safe — no secrets)
$logEntry = sprintf(
"API %s %s | %d | %s | %s",
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
$_SERVER['REQUEST_URI'] ?? '',
$code,
$success ? 'OK' : 'FAIL',
$message ?? 'N/A'
);
error_log($logEntry);
// Try custom log file
$logDir = STORAGE_PATH . '/logs';
$logFile = $logDir . '/app.log';
try {
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
if (is_writable($logDir) || is_writable($logFile)) {
@file_put_contents(
$logFile,
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
FILE_APPEND
);
}
} catch (\Exception $e) {
// Fallback silently
}
// HTTP Response
header('Content-Type: application/json; charset=utf-8');
http_response_code($code);
echo json_encode([
'success' => $success,
'data' => $data, // Return real data to client
'message' => $message,
'timestamp' => date('c')
], JSON_UNESCAPED_UNICODE);
exit;
}
function json_error(string $message, int $code = 400, $errors = null) {
json_response(false, $errors, $message, $code);
}
function json_success($data = null, ?string $message = 'Success', int $code = 200) {
json_response(true, $data, $message, $code);
}

13
app/config/database.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
/**
* Database Configuration
*/
return [
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => $_ENV['DB_PORT'] ?? '3306',
'database' => $_ENV['DB_DATABASE'] ?? 'musadaqDb',
'username' => $_ENV['DB_USERNAME'] ?? 'musadaqUser',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
];

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 كامل لتطبيق الهاتف',
'مدير حساب مخصص',
],
],
];

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

56
app/helpers/helpers.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
/**
* Global Helper Functions
*/
if (!function_exists('env')) {
function env(string $key, $default = null) {
return $_ENV[$key] ?? $default;
}
}
if (!function_exists('input')) {
function input(string $key = null, $default = null) {
static $inputData = null;
if ($inputData === null) {
$json = file_get_contents('php://input');
$inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []);
}
if ($key === null) return $inputData;
return $inputData[$key] ?? $default;
}
}
if (!function_exists('dd')) {
// M3 Fix: Guard dd() so it never leaks data in production
function dd(...$vars) {
if (env('APP_DEBUG', 'false') !== 'true') {
error_log('dd() called in production — suppressed. Check your code.');
json_error('Internal Server Error', 500);
}
header('Content-Type: text/html; charset=utf-8');
foreach ($vars as $v) {
echo "<pre style='background:#1e1e1e;color:#d4d4d4;padding:1rem;border-radius:4px;'>";
var_dump($v);
echo "</pre>";
}
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

@@ -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,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,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

@@ -0,0 +1,183 @@
<?php
/**
* Auth Login Endpoint
*/
use App\Core\Database;
use App\Core\JWT;
use App\Core\Validator;
use App\Middleware\RateLimitMiddleware;
use App\Core\Security;
// 0. Rate Limiting (5 attempts per minute per IP)
RateLimitMiddleware::check(5, 60);
$data = Security::sanitize(input());
// 1. Validation
$errors = Validator::validate($data, [
'email' => 'required|email',
'password' => 'required'
]);
if ($errors) {
json_error('Validation Failed', 422, $errors);
}
$email = $data['email'];
$password = $data['password'];
// 2. DB Check (Using hash for lookup since email is encrypted)
$db = Database::getInstance();
$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);
}
$deviceId = $data['device_id'] ?? null;
$isReviewer = (strtolower($email) === 'reviewer@musadaq.jo');
if ($deviceId && !$isReviewer) {
// Generate and send WhatsApp OTP
$phone = $user['phone'] ? (\App\Core\Encryption::decrypt($user['phone']) ?: $user['phone']) : null;
if (empty($phone)) {
json_error('رقم الهاتف غير مسجل لهذا المستخدم. يرجى التواصل مع المسؤول.', 403);
}
$phone = preg_replace('/[^0-9+]/', '', $phone);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
$otpHash = password_hash($otp, PASSWORD_DEFAULT);
$phoneHash = hash('sha256', $phone);
$cacheDir = STORAGE_PATH . '/cache/otp';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$otpData = [
'hash' => $otpHash,
'user_id' => $user['id'],
'attempts' => 0,
'max_attempts' => 5,
'expires_at' => time() + 300,
'created_at' => time(),
];
$fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w');
if ($fp) {
flock($fp, LOCK_EX);
fwrite($fp, json_encode($otpData));
flock($fp, LOCK_UN);
fclose($fp);
}
$whatsappService = new \App\Services\WhatsAppProxyService();
$message = "رمز التحقق لتطبيق مُصادَق:\n*{$otp}*\n\nصالح لمدة 5 دقائق.";
$result = $whatsappService->sendMessage($phone, $message);
if (!$result['success']) {
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
json_error('عذراً، فشل في إرسال رمز التحقق. يرجى المحاولة مرة أخرى.', 500);
}
if (env('APP_DEBUG', 'false') === 'true') {
error_log("DEV OTP for {$phone}: {$otp}");
}
json_success([
'otp_required' => true,
'phone' => $phone,
], 'تم إرسال رمز التحقق إلى رقم هاتفك المسجل عبر واتساب');
exit;
}
// 3. Handle device registration if provided (for mobile app login)
$deviceName = $data['device_name'] ?? 'Web Browser';
$deviceSecret = null;
if ($deviceId) {
$deviceSecret = hash('sha256', $user['id'] . $deviceId . bin2hex(random_bytes(16)));
$stmt = $db->prepare("
INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, device_secret, is_trusted, last_seen_at)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, TRUE, NOW())
ON DUPLICATE KEY UPDATE
device_name = VALUES(device_name),
platform = VALUES(platform),
app_version = VALUES(app_version),
device_secret = VALUES(device_secret),
is_trusted = TRUE,
last_seen_at = NOW(),
updated_at = NOW()
");
$stmt->execute([
$user['id'],
$deviceId,
$deviceName,
$data['platform'] ?? 'web',
$data['app_version'] ?? '1.0.0',
password_hash($deviceSecret, PASSWORD_DEFAULT),
]);
}
// 4. Issue Token
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
// Longer expiry for mobile (30 days), short for web (15 mins)
$expiry = $deviceId ? (30 * 24 * 3600) : (15 * 60);
$payload = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'device_id' => $deviceId,
'source' => $deviceId ? 'mobile' : 'web',
'exp' => time() + $expiry
];
$token = JWT::encode($payload, $secret);
// 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 = ?, 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' => (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,18 @@
<?php
/**
* Auth Logout Endpoint
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Check Authentication
$decoded = AuthMiddleware::check();
$userId = $decoded['user_id'];
// 2. Invalidate Refresh Token
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
$stmt->execute([$userId]);
json_success(null, 'تم تسجيل الخروج بنجاح');

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