Update: 2026-05-08 14:05:50

This commit is contained in:
Hamza-Ayed
2026-05-08 14:05:50 +03:00
parent cfc330e291
commit 155c2d0fc0
13 changed files with 709 additions and 22 deletions

View File

@@ -30,6 +30,13 @@ try {
} }
$phone = preg_replace('/[^0-9+]/', '', $data['phone']); $phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$phoneHash = hash('sha256', $phone); $phoneHash = hash('sha256', $phone);
// 2. Find user by phone hash OR plain phone (Support both schemas) // 2. Find user by phone hash OR plain phone (Support both schemas)

View File

@@ -30,6 +30,13 @@ if ($errors) {
} }
$phone = preg_replace('/[^0-9+]/', '', $data['phone']); $phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$phoneHash = hash('sha256', $phone); $phoneHash = hash('sha256', $phone);
$deviceId = $data['device_id'] ?? ''; $deviceId = $data['device_id'] ?? '';
$deviceName = $data['device_name'] ?? 'Unknown Device'; $deviceName = $data['device_name'] ?? 'Unknown Device';

View File

@@ -46,8 +46,16 @@ $encryptedName = Encryption::encrypt($data['name']);
$encryptedEmail = Encryption::encrypt($data['email']); $encryptedEmail = Encryption::encrypt($data['email']);
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login $emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
$encryptedPhone = Encryption::encrypt($data['phone']); $phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phoneHash = hash('sha256', preg_replace('/[^0-9+]/', '', $data['phone'])); $phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$encryptedPhone = Encryption::encrypt($phone);
$phoneHash = hash('sha256', $phone);
// 3. Determine Tenant ID // 3. Determine Tenant ID
$tenantId = null; $tenantId = null;

View File

@@ -34,14 +34,15 @@ $values = [];
if (isset($data['name'])) { if (isset($data['name'])) {
$fields[] = 'name = ?'; $fields[] = 'name = ?';
$values[] = $data['name']; $values[] = \App\Core\Encryption::encrypt($data['name']);
} }
if (isset($data['email'])) { if (isset($data['email'])) {
$fields[] = 'email = ?'; $fields[] = 'email = ?';
$values[] = $data['email']; $values[] = \App\Core\Encryption::encrypt($data['email']);
$fields[] = 'email_hash = ?';
$values[] = hash('sha256', strtolower($data['email']));
} }
if (isset($data['role'])) { if (isset($data['role'])) {
// Only super_admin can change roles
if ($role !== 'super_admin' && $data['role'] === 'super_admin') { if ($role !== 'super_admin' && $data['role'] === 'super_admin') {
json_error('لا يمكنك منح صلاحية مدير النظام', 403); json_error('لا يمكنك منح صلاحية مدير النظام', 403);
} }
@@ -49,8 +50,18 @@ if (isset($data['role'])) {
$values[] = $data['role']; $values[] = $data['role'];
} }
if (isset($data['phone'])) { if (isset($data['phone'])) {
$phone = preg_replace('/[^0-9+]/', '', $data['phone']);
$phone = ltrim($phone, '+');
if (str_starts_with($phone, '07')) {
$phone = '962' . substr($phone, 1);
} elseif (str_starts_with($phone, '7')) {
$phone = '962' . $phone;
}
$fields[] = 'phone = ?'; $fields[] = 'phone = ?';
$values[] = $data['phone']; $values[] = \App\Core\Encryption::encrypt($phone);
$fields[] = 'phone_hash = ?';
$values[] = hash('sha256', $phone);
} }
if (isset($data['is_active'])) { if (isset($data['is_active'])) {
$fields[] = 'is_active = ?'; $fields[] = 'is_active = ?';

View File

@@ -23,10 +23,22 @@ class AuthController extends GetxController {
return; return;
} }
isLoading.value = true; isLoading.value = true;
phone.value = phoneNumber;
// Normalize phone number
String normalizedPhone = phoneNumber.replaceAll(RegExp(r'[^0-9+]'), '');
if (normalizedPhone.startsWith('+')) {
normalizedPhone = normalizedPhone.substring(1);
}
if (normalizedPhone.startsWith('07')) {
normalizedPhone = '962' + normalizedPhone.substring(1);
} else if (normalizedPhone.startsWith('7')) {
normalizedPhone = '962' + normalizedPhone;
}
phone.value = normalizedPhone;
final response = await _dio.post('auth/mobile/request-otp', data: { final response = await _dio.post('auth/mobile/request-otp', data: {
'phone': phoneNumber, 'phone': normalizedPhone,
}); });
if (response.statusCode == 200) { if (response.statusCode == 200) {

View File

@@ -57,4 +57,27 @@ class CompaniesManagementController extends GetxController {
AppSnackbar.showError('خطأ', 'تعذر حذف الشركة'); AppSnackbar.showError('خطأ', 'تعذر حذف الشركة');
} }
} }
Future<void> connectJoFotara(String id, String clientId, String secretKey, String sequence) async {
try {
isLoading.value = true;
final response = await _dio.post('companies/connect_jofotara', data: {
'id': id,
'client_id': clientId,
'secret_key': secretKey,
'income_source_sequence': sequence,
});
if (response.data['success'] == true) {
await fetchCompanies();
AppSnackbar.showSuccess('نجاح', 'تم ربط الشركة بجوفوترا بنجاح');
} else {
AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل الربط');
}
} catch (e) {
AppLogger.error('Failed to connect jofotara', e);
AppSnackbar.showError('خطأ', 'تعذر ربط جوفوترا');
} finally {
isLoading.value = false;
}
}
} }

View File

@@ -137,6 +137,9 @@ class CompaniesManagementView extends StatelessWidget {
'company_name': company['name'], 'company_name': company['name'],
}); });
break; break;
case 'link_jofotara':
_showLinkJoFotaraDialog(context, company, controller);
break;
case 'delete': case 'delete':
_confirmDelete(context, controller, company['id'], company['name'] ?? ''); _confirmDelete(context, controller, company['id'], company['name'] ?? '');
break; break;
@@ -145,6 +148,7 @@ class CompaniesManagementView extends StatelessWidget {
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Row(children: [Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('تعديل البيانات')])), const PopupMenuItem(value: 'edit', child: Row(children: [Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('تعديل البيانات')])),
const PopupMenuItem(value: 'stats', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('الإحصائيات')])), const PopupMenuItem(value: 'stats', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('الإحصائيات')])),
const PopupMenuItem(value: 'link_jofotara', child: Row(children: [Icon(Icons.link, size: 18, color: Color(0xFF6366F1)), SizedBox(width: 8), Text('ربط جوفوترا', style: TextStyle(color: Color(0xFF6366F1)))])),
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, size: 18, color: Colors.red), SizedBox(width: 8), Text('حذف', style: TextStyle(color: Colors.red))])), const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, size: 18, color: Colors.red), SizedBox(width: 8), Text('حذف', style: TextStyle(color: Colors.red))])),
], ],
), ),
@@ -162,7 +166,7 @@ class CompaniesManagementView extends StatelessWidget {
if (company['address'] != null && company['address'].toString().isNotEmpty) if (company['address'] != null && company['address'].toString().isNotEmpty)
_chip(Icons.location_on, company['address'], Colors.orange), _chip(Icons.location_on, company['address'], Colors.orange),
if (company['is_jofotara_linked'] == 1) if (company['is_jofotara_linked'] == 1)
_chip(Icons.verified, 'جوفتورة', const Color(0xFF6366F1)), _chip(Icons.verified, 'جوفوترا', const Color(0xFF6366F1)),
], ],
), ),
@@ -298,4 +302,43 @@ class CompaniesManagementView extends StatelessWidget {
}, },
); );
} }
void _showLinkJoFotaraDialog(BuildContext context, Map<String, dynamic> company, CompaniesManagementController controller) {
final clientIdC = TextEditingController();
final secretKeyC = TextEditingController();
final sequenceC = TextEditingController();
Get.dialog(
AlertDialog(
title: const Text('ربط منصة جوفوترا', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF6366F1))),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('أدخل بيانات الربط الخاصة بالشركة:', style: TextStyle(fontSize: 13, color: Colors.grey), textAlign: TextAlign.center),
const SizedBox(height: 16),
_editField('Client ID', clientIdC, Icons.vpn_key),
_editField('Secret Key', secretKeyC, Icons.lock),
_editField('Income Source Sequence (اختياري)', sequenceC, Icons.format_list_numbered),
],
),
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('إلغاء')),
ElevatedButton(
onPressed: () {
if (clientIdC.text.isEmpty || secretKeyC.text.isEmpty) {
Get.snackbar('تنبيه', 'يجب إدخال الـ Client ID و Secret Key');
return;
}
Get.back();
controller.connectJoFotara(company['id'], clientIdC.text, secretKeyC.text, sequenceC.text);
},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6366F1)),
child: const Text('ربط الآن', style: TextStyle(color: Colors.white)),
),
],
),
);
}
} }

View File

@@ -25,6 +25,7 @@ class DashboardController extends GetxController {
var isLoading = true.obs; var isLoading = true.obs;
var stats = {}.obs; var stats = {}.obs;
var gamification = {}.obs;
var recentActivities = [].obs; var recentActivities = [].obs;
var userRole = ''.obs; var userRole = ''.obs;
@@ -67,6 +68,12 @@ class DashboardController extends GetxController {
} }
} }
// Fetch Gamification
final gamificationResponse = await _dio.get('gamification/profile');
if (gamificationResponse.data['success'] == true) {
gamification.value = gamificationResponse.data['data'];
}
// Fetch Recent Activity // Fetch Recent Activity
final activityResponse = await _dio.get('dashboard/recent-activity'); final activityResponse = await _dio.get('dashboard/recent-activity');
if (activityResponse.data['success'] == true) { if (activityResponse.data['success'] == true) {

View File

@@ -74,6 +74,10 @@ class DashboardView extends GetView<DashboardController> {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildAdminQuickActions(role, isDark), _buildAdminQuickActions(role, isDark),
], ],
if (controller.gamification.isNotEmpty) ...[
const SizedBox(height: 24),
_buildGamificationCard(controller.gamification, isDark),
],
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('إحصائيات الفواتير', const Text('إحصائيات الفواتير',
style: TextStyle( style: TextStyle(
@@ -386,6 +390,96 @@ class DashboardView extends GetView<DashboardController> {
); );
} }
Widget _buildGamificationCard(Map gamification, bool isDark) {
final points = gamification['points'] ?? 0;
final level = gamification['level'] ?? 1;
final levelName = gamification['level_name'] ?? 'مبتدئ';
final currentLevelThreshold = gamification['current_level_threshold'] ?? 0;
final nextLevelThreshold = gamification['next_level_threshold'] ?? 1000;
final progress = (points - currentLevelThreshold) /
((nextLevelThreshold - currentLevelThreshold) > 0
? (nextLevelThreshold - currentLevelThreshold)
: 1);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.stars_rounded, color: Colors.amber, size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'المستوى $level: $levelName',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'$points نقطة مكتسبة',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'المكافآت',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0).toDouble(),
backgroundColor: Colors.white24,
color: Colors.amber,
minHeight: 8,
),
),
const SizedBox(height: 8),
Text(
'باقي ${nextLevelThreshold - points} نقطة للمستوى القادم',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
);
}
Widget _buildQuotaMeter(Map subscription, bool isDark) { Widget _buildQuotaMeter(Map subscription, bool isDark) {
int limit = subscription['limit'] ?? 100; int limit = subscription['limit'] ?? 100;
int used = subscription['used'] ?? 0; int used = subscription['used'] ?? 0;

View File

@@ -226,7 +226,7 @@ class InvoiceDetailController extends GetxController {
AlertDialog( AlertDialog(
title: const Text('تأكيد الإرسال'), title: const Text('تأكيد الإرسال'),
content: const Text( content: const Text(
'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفتورة؟\nلا يمكن التراجع عن هذا الإجراء.'), 'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفوترا؟\nلا يمكن التراجع عن هذا الإجراء.'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Get.back(result: false), onPressed: () => Get.back(result: false),
@@ -246,21 +246,21 @@ class InvoiceDetailController extends GetxController {
if (confirmed != true) return; if (confirmed != true) return;
try { try {
AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفتورة...'); AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفوترا...');
final res = await DioClient().client.post( final res = await DioClient().client.post(
'invoices/submit-jofotara', 'invoices/submit-jofotara',
data: {'invoice_id': invoiceId}, data: {'invoice_id': invoiceId},
); );
if (res.data['success'] == true) { if (res.data['success'] == true) {
AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح'); AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفوترا بنجاح');
fetchInvoiceDetails(); fetchInvoiceDetails();
} else { } else {
AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال'); AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال');
} }
} catch (e) { } catch (e) {
AppLogger.error('Failed to submit to JoFotara', e); AppLogger.error('Failed to submit to JoFotara', e);
AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفتورة'); AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا');
} }
} }
} }

View File

@@ -135,7 +135,7 @@ class InvoiceDetailView extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
icon: const Icon(Icons.send_rounded), icon: const Icon(Icons.send_rounded),
label: const Text('إرسال لجوفتورة', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), label: const Text('إرسال لجوفوترا', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
), ),
), ),
], ],
@@ -268,7 +268,7 @@ class InvoiceDetailView extends StatelessWidget {
const Icon(Icons.verified, color: Color(0xFF10B981), size: 20), const Icon(Icons.verified, color: Color(0xFF10B981), size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'مُقدَّمة لجوفتورة', 'مُقدَّمة لجوفوترا',
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)), style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)),
), ),
], ],
@@ -489,7 +489,7 @@ class InvoiceDetailView extends StatelessWidget {
break; break;
case 'submitted': case 'submitted':
color = const Color(0xFF6366F1); color = const Color(0xFF6366F1);
text = 'مُقدَّمة لجوفتورة'; text = 'مُقدَّمة لجوفوترا';
break; break;
default: default:
color = const Color(0xFFF59E0B); color = const Color(0xFFF59E0B);

View File

@@ -853,9 +853,9 @@
<p>لا رسوم خفية. ابدأ مجاناً وقم بالترقية عندما تحتاج إلى المزيد من السعة.</p> <p>لا رسوم خفية. ابدأ مجاناً وقم بالترقية عندما تحتاج إلى المزيد من السعة.</p>
</div> </div>
<div class="pricing-grid"> <div class="pricing-grid">
<!-- Free Plan --> <!-- Trial Plan -->
<div class="price-card"> <div class="price-card">
<div class="price-name">تريال (Trial)</div> <div class="price-name">المجانية (Trial)</div>
<div class="price-amount">0 <span>JOD / شهر</span></div> <div class="price-amount">0 <span>JOD / شهر</span></div>
<ul class="price-features"> <ul class="price-features">
<li><span class="feature-check"></span> إدارة شركة واحدة</li> <li><span class="feature-check"></span> إدارة شركة واحدة</li>
@@ -868,7 +868,7 @@
<!-- Basic Plan --> <!-- Basic Plan -->
<div class="price-card"> <div class="price-card">
<div class="price-name">أساسية (Basic)</div> <div class="price-name">الأساسية (Basic)</div>
<div class="price-amount">15 <span>JOD / شهر</span></div> <div class="price-amount">15 <span>JOD / شهر</span></div>
<ul class="price-features"> <ul class="price-features">
<li><span class="feature-check"></span> إدارة 3 شركات</li> <li><span class="feature-check"></span> إدارة 3 شركات</li>
@@ -879,20 +879,32 @@
<a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">اشترك الآن</a> <a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
</div> </div>
<!-- Office Plan --> <!-- Professional Plan -->
<div class="price-card popular"> <div class="price-card popular">
<div class="popular-badge">الأكثر اختياراً</div> <div class="popular-badge">الأكثر اختياراً</div>
<div class="price-name">مكتبية (Office)</div> <div class="price-name">الاحترافية (Professional)</div>
<div class="price-amount">45 <span>JOD / شهر</span></div> <div class="price-amount">45 <span>JOD / شهر</span></div>
<ul class="price-features"> <ul class="price-features">
<li><span class="feature-check"></span> إدارة حتى 10 شركات</li> <li><span class="feature-check"></span> إدارة حتى 10 شركات</li>
<li><span class="feature-check"></span> 500 فاتورة شهرياً</li> <li><span class="feature-check"></span> 500 فاتورة شهرياً</li>
<li><span class="feature-check"></span> 10 مستخدمين مع صلاحيات</li> <li><span class="feature-check"></span> 10 مستخدمين مع صلاحيات</li>
<li><span class="feature-check"></span> تصدير Excel متقدم</li> <li><span class="feature-check"></span> تصدير Excel متقدم</li>
<li><span class="feature-check"></span> أولوية الدعم الفني</li>
</ul> </ul>
<a href="/register.php" class="btn btn-primary" style="width:100%; margin-top: 1rem;">اشترك الآن</a> <a href="/register.php" class="btn btn-primary" style="width:100%; margin-top: 1rem;">اشترك الآن</a>
</div> </div>
<!-- Enterprise Plan -->
<div class="price-card">
<div class="price-name">المؤسسية (Enterprise)</div>
<div class="price-amount">مخصص <span>JOD / شهر</span></div>
<ul class="price-features">
<li><span class="feature-check"></span> شركات غير محدودة</li>
<li><span class="feature-check"></span> فواتير غير محدودة</li>
<li><span class="feature-check"></span> مستخدمين غير محدودين</li>
<li><span class="feature-check"></span> أولوية الدعم الفني</li>
</ul>
<a href="/register.php" class="btn btn-outline" style="width:100%; margin-top: 1rem;">تواصل معنا</a>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -259,6 +259,17 @@ foreach ($users as $user) {
``` ```
## File: `reset_queue.sql`
```sql
-- Reset failed queue items so the cron worker retries them
UPDATE invoice_processing_queue SET status = 'pending', error_message = NULL WHERE status = 'failed';
-- Verify current queue state
SELECT id, batch_id, status, error_message, created_at FROM invoice_processing_queue ORDER BY created_at;
```
## File: `schema.sql` ## File: `schema.sql`
```sql ```sql
@@ -714,6 +725,26 @@ foreach ($tables as $table) {
``` ```
## File: `create_ai_usage_table.sql`
```sql
-- AI Usage Log — Token tracking for cost analysis
CREATE TABLE IF NOT EXISTS ai_usage_log (
id CHAR(36) PRIMARY KEY,
input_tokens INT UNSIGNED NOT NULL DEFAULT 0,
output_tokens INT UNSIGNED NOT NULL DEFAULT 0,
total_tokens INT UNSIGNED NOT NULL DEFAULT 0,
cost_usd DECIMAL(12, 8) NOT NULL DEFAULT 0,
cost_jod DECIMAL(12, 8) NOT NULL DEFAULT 0,
model VARCHAR(50) NOT NULL DEFAULT 'gemini-flash-lite',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at),
INDEX idx_model (model)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `migrate.php` ## File: `migrate.php`
```php ```php
@@ -892,6 +923,122 @@ try {
``` ```
## File: `phase1_migration.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Phase 1: AI Usage Tracking + Notifications
-- ════════════════════════════════════════════════════════════
-- AI Usage Log (tracks every AI request)
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
company_id CHAR(36) NULL,
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
model_name VARCHAR(50) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
total_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_date (tenant_id, created_at),
INDEX idx_action (action_type),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Notifications
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Referral Codes (Phase 2 prep)
CREATE TABLE IF NOT EXISTS referral_codes (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id CHAR(36) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
uses_count INT DEFAULT 0,
max_uses INT DEFAULT 50,
reward_months INT DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Referral Uses (Phase 2 prep)
CREATE TABLE IF NOT EXISTS referral_uses (
id INT AUTO_INCREMENT PRIMARY KEY,
code_id INT NOT NULL,
referred_tenant_id CHAR(36) NOT NULL,
reward_applied BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (code_id) REFERENCES referral_codes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- User Achievements (Phase 2 prep)
CREATE TABLE IF NOT EXISTS user_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id CHAR(36) NOT NULL,
achievement_code VARCHAR(50) NOT NULL,
points INT NOT NULL DEFAULT 0,
earned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_user_achievement (user_id, achievement_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `update_pricing.sql`
```sql
-- Restore original Musadaq subscription pricing
-- Premium pricing justified by AI extraction + JoFotara + mobile app
UPDATE subscription_plans SET
name_ar = 'مجانية', name_en = 'Free',
max_companies = 1, max_invoices_month = 15, max_users = 1,
price_jod = 0.00, jofotara_enabled = 1
WHERE id = 'free';
UPDATE subscription_plans SET
name_ar = 'أساسية', name_en = 'Basic',
max_companies = 3, max_invoices_month = 100, max_users = 3,
price_jod = 15.00, jofotara_enabled = 1
WHERE id = 'basic';
UPDATE subscription_plans SET
name_ar = 'مكتبية', name_en = 'Office',
max_companies = 10, max_invoices_month = 500, max_users = 10,
price_jod = 45.00, jofotara_enabled = 1
WHERE id = 'office';
UPDATE subscription_plans SET
name_ar = 'احترافية', name_en = 'Pro',
max_companies = 25, max_invoices_month = 2000, max_users = 25,
price_jod = 99.00, jofotara_enabled = 1
WHERE id = 'pro';
UPDATE subscription_plans SET
name_ar = 'مؤسسية', name_en = 'Enterprise',
max_companies = 999, max_invoices_month = 99999, max_users = 999,
price_jod = 249.00, jofotara_enabled = 1
WHERE id = 'enterprise';
```
## File: `backfill_hashes.php` ## File: `backfill_hashes.php`
```php ```php
@@ -1135,6 +1282,210 @@ echo "════════════════════════
``` ```
## File: `deploy_production.sh`
```sh
#!/bin/bash
# ─────────────────────────────────────────────────────
# Musadaq Production Deployment Script
# Run this on the production server after syncing files
# ─────────────────────────────────────────────────────
set -e
echo "═══════════════════════════════════════════════"
echo " مُصادَق — Production Deployment Script"
echo "═══════════════════════════════════════════════"
# 1. Install PHP dependencies
echo ""
echo "▶ Step 1: Installing Composer dependencies..."
cd /home/musadaq/htdocs/musadaq.intaleqapp.com
composer install --no-dev --optimize-autoloader
# 2. Ensure storage directories exist
echo ""
echo "▶ Step 2: Creating storage directories..."
mkdir -p storage/invoices
mkdir -p storage/logs
mkdir -p storage/exports
mkdir -p storage/temp
chmod -R 775 storage/
# 3. Set up the Cron Job for AI Queue Worker
echo ""
echo "▶ Step 3: Setting up Cron Job for AI Worker..."
echo ""
echo " Run: crontab -e"
echo " Add this line:"
echo ""
echo " * * * * * /usr/bin/php /home/musadaq/htdocs/musadaq.intaleqapp.com/app/cron/process_batches.php >> /home/musadaq/htdocs/musadaq.intaleqapp.com/storage/logs/cron.log 2>&1"
echo ""
echo " This runs the AI Queue Worker every minute."
echo " The worker has its own lock file to prevent duplicates."
echo ""
# 4. Verify environment variables
echo "▶ Step 4: Checking .env configuration..."
if [ -f .env ]; then
echo " ✅ .env file found"
# Check critical keys
grep -q "GEMINI_API_KEY" .env && echo " ✅ GEMINI_API_KEY set" || echo " ❌ GEMINI_API_KEY missing!"
grep -q "DB_HOST" .env && echo " ✅ DB_HOST set" || echo " ❌ DB_HOST missing!"
grep -q "ENCRYPTION_KEY" .env && echo " ✅ ENCRYPTION_KEY set" || echo " ❌ ENCRYPTION_KEY missing!"
grep -q "JWT_SECRET" .env && echo " ✅ JWT_SECRET set" || echo " ❌ JWT_SECRET missing!"
grep -q "FCM_SERVER_KEY\|FIREBASE" .env && echo " ✅ Firebase key set" || echo " ⚠️ Firebase key missing (push notifications won't work)"
else
echo " ❌ .env file not found! Copy .env.example and configure it."
fi
echo ""
echo "═══════════════════════════════════════════════"
echo " ✅ Deployment Complete!"
echo ""
echo " Next steps:"
echo " 1. Add the Cron Job (shown above)"
echo " 2. Test the API: curl https://musadaq.intaleqapp.com/api/v1/auth/login"
echo " 3. Monitor logs: tail -f storage/logs/cron.log"
echo "═══════════════════════════════════════════════"
```
## File: `create_referral_tables.sql`
```sql
-- Referral System Tables
CREATE TABLE IF NOT EXISTS referral_codes (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_code (code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS referrals (
id CHAR(36) PRIMARY KEY,
referrer_id CHAR(36) NOT NULL,
referred_id CHAR(36) NULL,
referral_code VARCHAR(20) NOT NULL,
status ENUM('clicked', 'registered', 'subscribed') DEFAULT 'clicked',
reward_claimed TINYINT(1) DEFAULT 0,
reward_type VARCHAR(50) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
converted_at TIMESTAMP NULL,
INDEX idx_referrer (referrer_id),
INDEX idx_code (referral_code),
FOREIGN KEY (referrer_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `complete_migration.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Complete Phase 1 Migration (MySQL 8.0 Compatible)
-- ════════════════════════════════════════════════════════════
-- 1. Invoice Line Items (AI extracted data)
CREATE TABLE IF NOT EXISTS invoice_lines (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
line_number INT NOT NULL,
description VARCHAR(255) NOT NULL,
quantity DECIMAL(10,3) DEFAULT 1,
unit_price DECIMAL(15,4) NOT NULL,
tax_rate DECIMAL(5,2) DEFAULT 16.00,
tax_amount DECIMAL(15,4) DEFAULT 0,
discount DECIMAL(15,4) DEFAULT 0,
total_amount DECIMAL(15,4) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_invoice (invoice_id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. JoFotara Submissions Log
CREATE TABLE IF NOT EXISTS jofotara_submissions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
jofotara_uuid VARCHAR(100) NULL,
xml_content LONGTEXT NULL,
status ENUM('accepted', 'rejected', 'pending') DEFAULT 'pending',
qr_code_raw TEXT NULL,
response_body JSON NULL,
submitted_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_invoice (invoice_id),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. AI Usage Log
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
company_id CHAR(36) NULL,
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
model_name VARCHAR(50) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
total_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_date (tenant_id, created_at),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. Notifications
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ════════════════════════════════════════════════════════════
-- 5. Safe ALTER TABLE (MySQL 8 compatible — no IF NOT EXISTS)
-- Run each block separately. If column already exists,
-- MySQL will show "Duplicate column" error — just skip it.
-- ════════════════════════════════════════════════════════════
-- 5a. Companies: JoFotara credentials
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
ALTER TABLE companies ADD COLUMN jofotara_client_id VARCHAR(255) NULL;
ALTER TABLE companies ADD COLUMN jofotara_secret_key VARCHAR(255) NULL;
ALTER TABLE companies ADD COLUMN jofotara_status ENUM('active', 'inactive', 'pending') DEFAULT 'inactive';
-- 5b. Invoices: AI + JoFotara metadata
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
ALTER TABLE invoices ADD COLUMN invoice_category ENUM('simplified', 'standard') DEFAULT 'simplified';
ALTER TABLE invoices ADD COLUMN ubl_type_code VARCHAR(10) DEFAULT '388';
ALTER TABLE invoices ADD COLUMN payment_method_code VARCHAR(10) DEFAULT '013';
ALTER TABLE invoices ADD COLUMN validation_warnings JSON NULL;
ALTER TABLE invoices ADD COLUMN ai_confidence DECIMAL(5,2) DEFAULT 0;
ALTER TABLE invoices ADD COLUMN jofotara_uuid VARCHAR(100) NULL;
```
## File: `debug_data.php` ## File: `debug_data.php`
```php ```php
@@ -1158,3 +1509,115 @@ print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
``` ```
## File: `create_test_account.php`
```php
<?php
/**
* Create Test Account for App Reviewers
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
try {
$db = Database::getInstance();
$db->beginTransaction();
// 1. Generate UUIDs
$tenantId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$userId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
// 2. Test Account Data
$tenantName = "مكتب المراجعة التجريبي";
$tenantEmail = "reviewer@musadaq.jo";
$userName = "App Reviewer";
$userEmail = "reviewer@musadaq.jo";
$userPassword = "Reviewer2026!";
// 3. Encrypt data
$encryptedTenantName = Encryption::encrypt($tenantName);
$encryptedTenantEmail = Encryption::encrypt($tenantEmail);
$encryptedUserName = Encryption::encrypt($userName);
$encryptedUserEmail = Encryption::encrypt($userEmail);
$emailHash = hash('sha256', strtolower($userEmail));
$passwordHash = password_hash($userPassword, PASSWORD_DEFAULT);
// 4. Delete existing if any (prevent duplicates on re-run)
$stmt = $db->prepare("DELETE FROM users WHERE email_hash = ?");
$stmt->execute([$emailHash]);
// 5. Insert Tenant
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, ?, ?, 'active', NOW())");
$stmt->execute([
$tenantId,
$encryptedTenantName,
$encryptedTenantEmail
]);
// 6. Insert User (Manager)
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, 'admin', NOW())");
$stmtUser->execute([
$userId,
$tenantId,
$encryptedUserName,
$encryptedUserEmail,
$emailHash,
$passwordHash
]);
// 7. Insert Gamification Profile (Optional but good for testing dashboard)
$stmtProfile = $db->prepare("INSERT INTO user_profiles (user_id, points, current_level, rank_title) VALUES (?, 1500, 2, 'مُحاسب مبتدئ') ON DUPLICATE KEY UPDATE points=1500");
$stmtProfile->execute([$userId]);
$db->commit();
echo "✅ Test Account Created Successfully!\n";
echo "=====================================\n";
echo "Email: $userEmail\n";
echo "Password: $userPassword\n";
echo "=====================================\n";
} catch (\Exception $e) {
$db->rollBack();
echo "❌ Error: " . $e->getMessage() . "\n";
}
```
## File: `create_notifications_table.sql`
```sql
-- Notifications Table
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
type ENUM('info', 'success', 'warning', 'error') DEFAULT 'info',
category VARCHAR(50) DEFAULT 'general',
entity_type VARCHAR(50) NULL,
entity_id CHAR(36) NULL,
is_read TINYINT(1) DEFAULT 0,
read_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```