diff --git a/app/modules_app/auth/mobile_request_otp.php b/app/modules_app/auth/mobile_request_otp.php index 4e02ec5..aceaf5b 100644 --- a/app/modules_app/auth/mobile_request_otp.php +++ b/app/modules_app/auth/mobile_request_otp.php @@ -30,6 +30,13 @@ try { } $phone = preg_replace('/[^0-9+]/', '', $data['phone']); + $phone = ltrim($phone, '+'); + if (str_starts_with($phone, '07')) { + $phone = '962' . substr($phone, 1); + } elseif (str_starts_with($phone, '7')) { + $phone = '962' . $phone; + } + $phoneHash = hash('sha256', $phone); // 2. Find user by phone hash OR plain phone (Support both schemas) diff --git a/app/modules_app/auth/mobile_verify_otp.php b/app/modules_app/auth/mobile_verify_otp.php index ea6a0c1..943224b 100644 --- a/app/modules_app/auth/mobile_verify_otp.php +++ b/app/modules_app/auth/mobile_verify_otp.php @@ -30,6 +30,13 @@ if ($errors) { } $phone = preg_replace('/[^0-9+]/', '', $data['phone']); +$phone = ltrim($phone, '+'); +if (str_starts_with($phone, '07')) { + $phone = '962' . substr($phone, 1); +} elseif (str_starts_with($phone, '7')) { + $phone = '962' . $phone; +} + $phoneHash = hash('sha256', $phone); $deviceId = $data['device_id'] ?? ''; $deviceName = $data['device_name'] ?? 'Unknown Device'; diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index 27fe2d4..c759158 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -46,8 +46,16 @@ $encryptedName = Encryption::encrypt($data['name']); $encryptedEmail = Encryption::encrypt($data['email']); $emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login -$encryptedPhone = Encryption::encrypt($data['phone']); -$phoneHash = hash('sha256', 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; +} + +$encryptedPhone = Encryption::encrypt($phone); +$phoneHash = hash('sha256', $phone); // 3. Determine Tenant ID $tenantId = null; diff --git a/app/modules_app/users/update.php b/app/modules_app/users/update.php index bf7fb99..f55008d 100644 --- a/app/modules_app/users/update.php +++ b/app/modules_app/users/update.php @@ -34,14 +34,15 @@ $values = []; if (isset($data['name'])) { $fields[] = 'name = ?'; - $values[] = $data['name']; + $values[] = \App\Core\Encryption::encrypt($data['name']); } if (isset($data['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'])) { - // Only super_admin can change roles if ($role !== 'super_admin' && $data['role'] === 'super_admin') { json_error('لا يمكنك منح صلاحية مدير النظام', 403); } @@ -49,8 +50,18 @@ if (isset($data['role'])) { $values[] = $data['role']; } if (isset($data['phone'])) { + $phone = preg_replace('/[^0-9+]/', '', $data['phone']); + $phone = ltrim($phone, '+'); + if (str_starts_with($phone, '07')) { + $phone = '962' . substr($phone, 1); + } elseif (str_starts_with($phone, '7')) { + $phone = '962' . $phone; + } + $fields[] = 'phone = ?'; - $values[] = $data['phone']; + $values[] = \App\Core\Encryption::encrypt($phone); + $fields[] = 'phone_hash = ?'; + $values[] = hash('sha256', $phone); } if (isset($data['is_active'])) { $fields[] = 'is_active = ?'; diff --git a/musadaq-app/lib/features/auth/controllers/auth_controller.dart b/musadaq-app/lib/features/auth/controllers/auth_controller.dart index f742860..cbc81f7 100644 --- a/musadaq-app/lib/features/auth/controllers/auth_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/auth_controller.dart @@ -23,10 +23,22 @@ class AuthController extends GetxController { return; } 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: { - 'phone': phoneNumber, + 'phone': normalizedPhone, }); if (response.statusCode == 200) { diff --git a/musadaq-app/lib/features/companies/controllers/companies_management_controller.dart b/musadaq-app/lib/features/companies/controllers/companies_management_controller.dart index c6e7b02..db0ef28 100644 --- a/musadaq-app/lib/features/companies/controllers/companies_management_controller.dart +++ b/musadaq-app/lib/features/companies/controllers/companies_management_controller.dart @@ -57,4 +57,27 @@ class CompaniesManagementController extends GetxController { AppSnackbar.showError('خطأ', 'تعذر حذف الشركة'); } } + + Future 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; + } + } } diff --git a/musadaq-app/lib/features/companies/views/companies_management_view.dart b/musadaq-app/lib/features/companies/views/companies_management_view.dart index cfac187..85c3b7f 100644 --- a/musadaq-app/lib/features/companies/views/companies_management_view.dart +++ b/musadaq-app/lib/features/companies/views/companies_management_view.dart @@ -137,6 +137,9 @@ class CompaniesManagementView extends StatelessWidget { 'company_name': company['name'], }); break; + case 'link_jofotara': + _showLinkJoFotaraDialog(context, company, controller); + break; case 'delete': _confirmDelete(context, controller, company['id'], company['name'] ?? ''); break; @@ -145,6 +148,7 @@ class CompaniesManagementView extends StatelessWidget { itemBuilder: (context) => [ 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: '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))])), ], ), @@ -162,7 +166,7 @@ class CompaniesManagementView extends StatelessWidget { if (company['address'] != null && company['address'].toString().isNotEmpty) _chip(Icons.location_on, company['address'], Colors.orange), 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 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)), + ), + ], + ), + ); + } } diff --git a/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart index bac3db8..1ea4f3a 100644 --- a/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart +++ b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart @@ -25,6 +25,7 @@ class DashboardController extends GetxController { var isLoading = true.obs; var stats = {}.obs; + var gamification = {}.obs; var recentActivities = [].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 final activityResponse = await _dio.get('dashboard/recent-activity'); if (activityResponse.data['success'] == true) { diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index 41b1a31..44653b3 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -74,6 +74,10 @@ class DashboardView extends GetView { const SizedBox(height: 24), _buildAdminQuickActions(role, isDark), ], + if (controller.gamification.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildGamificationCard(controller.gamification, isDark), + ], const SizedBox(height: 32), const Text('إحصائيات الفواتير', style: TextStyle( @@ -386,6 +390,96 @@ class DashboardView extends GetView { ); } + 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) { int limit = subscription['limit'] ?? 100; int used = subscription['used'] ?? 0; diff --git a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart index 1e02ba1..bbf38cf 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -226,7 +226,7 @@ class InvoiceDetailController extends GetxController { AlertDialog( title: const Text('تأكيد الإرسال'), content: const Text( - 'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفتورة؟\nلا يمكن التراجع عن هذا الإجراء.'), + 'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفوترا؟\nلا يمكن التراجع عن هذا الإجراء.'), actions: [ TextButton( onPressed: () => Get.back(result: false), @@ -246,21 +246,21 @@ class InvoiceDetailController extends GetxController { if (confirmed != true) return; try { - AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفتورة...'); + AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفوترا...'); final res = await DioClient().client.post( 'invoices/submit-jofotara', data: {'invoice_id': invoiceId}, ); if (res.data['success'] == true) { - AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح'); + AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفوترا بنجاح'); fetchInvoiceDetails(); } else { AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال'); } } catch (e) { AppLogger.error('Failed to submit to JoFotara', e); - AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفتورة'); + AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفوترا'); } } } diff --git a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart index 1ca67a4..a51e2fc 100644 --- a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart @@ -135,7 +135,7 @@ class InvoiceDetailView extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), 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 SizedBox(width: 8), const Text( - 'مُقدَّمة لجوفتورة ✓', + 'مُقدَّمة لجوفوترا ✓', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)), ), ], @@ -489,7 +489,7 @@ class InvoiceDetailView extends StatelessWidget { break; case 'submitted': color = const Color(0xFF6366F1); - text = 'مُقدَّمة لجوفتورة'; + text = 'مُقدَّمة لجوفوترا'; break; default: color = const Color(0xFFF59E0B); diff --git a/public/landing.php b/public/landing.php index 3b075e1..d578a08 100644 --- a/public/landing.php +++ b/public/landing.php @@ -853,9 +853,9 @@

لا رسوم خفية. ابدأ مجاناً وقم بالترقية عندما تحتاج إلى المزيد من السعة.

- +
-
تريال (Trial)
+
المجانية (Trial)
0 JOD / شهر
  • إدارة شركة واحدة
  • @@ -868,7 +868,7 @@
    -
    أساسية (Basic)
    +
    الأساسية (Basic)
    15 JOD / شهر
    - + + + +
    +
    المؤسسية (Enterprise)
    +
    مخصص JOD / شهر
    +
      +
    • شركات غير محدودة
    • +
    • فواتير غير محدودة
    • +
    • مستخدمين غير محدودين
    • +
    • أولوية الدعم الفني
    • +
    + تواصل معنا +
diff --git a/scripts/PROJECT_DOCUMENTATION.md b/scripts/PROJECT_DOCUMENTATION.md index 6eefa8d..0191323 100644 --- a/scripts/PROJECT_DOCUMENTATION.md +++ b/scripts/PROJECT_DOCUMENTATION.md @@ -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` ```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` ```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` ```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` ```php @@ -1158,3 +1509,115 @@ print_r($stmt->fetchAll(PDO::FETCH_ASSOC)); ``` +## File: `create_test_account.php` + +```php +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; + +``` +