Update: 2026-05-08 04:58:23
This commit is contained in:
93
app/modules_app/academy/articles.php
Normal file
93
app/modules_app/academy/articles.php
Normal 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),
|
||||
], 'أكاديمية مُصادَق');
|
||||
@@ -51,5 +51,5 @@ try {
|
||||
json_success(null, 'تم تخصيص المستخدم للشركة بنجاح');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('حدث خطأ أثناء التخصيص: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'assignments/create', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.');
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@ try {
|
||||
json_success($assignments);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('SQL Error: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'assignments/index');
|
||||
}
|
||||
|
||||
@@ -117,5 +117,5 @@ try {
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log("Audit log error: " . $e->getMessage());
|
||||
json_error('خطأ في جلب سجل النشاط: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'audit/index', 'خطأ في جلب سجل النشاط.');
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ try {
|
||||
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('Internal Server Error: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'auth/mobile_request_otp');
|
||||
}
|
||||
|
||||
|
||||
|
||||
88
app/modules_app/chatbot/ask.php
Normal file
88
app/modules_app/chatbot/ask.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* AI Accounting Chatbot — "اسأل مُصادَق"
|
||||
* POST /v1/chatbot/ask
|
||||
* Body: { "question": "كم ضريبة المبيعات على الخدمات الرقمية؟" }
|
||||
*
|
||||
* AI-powered chatbot that answers accounting & tax questions
|
||||
* with context from the user's own data when relevant.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\AI;
|
||||
use App\Core\Encryption;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$question = trim($data['question'] ?? '');
|
||||
|
||||
if (empty($question) || mb_strlen($question) < 3) {
|
||||
json_error('يرجى كتابة سؤالك (3 أحرف على الأقل)', 422);
|
||||
}
|
||||
|
||||
if (mb_strlen($question) > 500) {
|
||||
json_error('السؤال طويل جداً (الحد 500 حرف)', 422);
|
||||
}
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
$userId = $decoded['user_id'];
|
||||
|
||||
try {
|
||||
// 1. Gather user context (last month stats)
|
||||
$contextStmt = $db->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_invoices,
|
||||
COALESCE(SUM(grand_total), 0) as total_revenue,
|
||||
COALESCE(SUM(tax_amount), 0) as total_tax
|
||||
FROM invoices
|
||||
WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
");
|
||||
$contextStmt->execute([$tenantId]);
|
||||
$context = $contextStmt->fetch();
|
||||
|
||||
$companyStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||
$companyStmt->execute([$tenantId]);
|
||||
$companyCount = (int)$companyStmt->fetchColumn();
|
||||
|
||||
// 2. Build AI prompt
|
||||
$systemPrompt = <<<PROMPT
|
||||
أنت "مُصادَق" — مساعد محاسبي ذكي متخصص في المحاسبة والضرائب الأردنية.
|
||||
|
||||
قواعد:
|
||||
1. أجب بالعربية دائماً وبشكل مختصر ومفيد
|
||||
2. إذا كان السؤال عن ضرائب أردنية، استخدم نسب ضريبة المبيعات الأردنية (16% عامة، 4% و8% مخفضة، 0% معفاة)
|
||||
3. إذا كان السؤال غير محاسبي، قل "أنا متخصص بالمحاسبة والضرائب فقط"
|
||||
4. لا تعطِ نصائح قانونية نهائية — انصح بمراجعة محاسب قانوني للحالات المعقدة
|
||||
5. إذا كان السؤال يتعلق ببيانات المستخدم، استخدم السياق المتاح
|
||||
|
||||
سياق المستخدم (آخر 30 يوم):
|
||||
- عدد الفواتير: {$context['total_invoices']}
|
||||
- إجمالي الإيرادات: {$context['total_revenue']} دينار
|
||||
- إجمالي الضريبة: {$context['total_tax']} دينار
|
||||
- عدد الشركات: {$companyCount}
|
||||
PROMPT;
|
||||
|
||||
$aiResponse = AI::chat($systemPrompt, $question, $tenantId);
|
||||
|
||||
if (!$aiResponse) {
|
||||
json_error('عذراً، لم أتمكن من معالجة سؤالك. حاول مرة أخرى.', 500);
|
||||
}
|
||||
|
||||
// 3. Log the conversation
|
||||
$db->prepare("
|
||||
INSERT INTO chatbot_history (id, user_id, tenant_id, question, answer, created_at)
|
||||
VALUES (UUID(), ?, ?, ?, ?, NOW())
|
||||
")->execute([$userId, $tenantId, $question, $aiResponse]);
|
||||
|
||||
json_success([
|
||||
'answer' => $aiResponse,
|
||||
'question' => $question,
|
||||
'timestamp' => date('c'),
|
||||
], 'إجابة مُصادَق');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
safe_error($e, 'chatbot/ask', 'حدث خطأ في المساعد الذكي.');
|
||||
}
|
||||
29
app/modules_app/chatbot/history.php
Normal file
29
app/modules_app/chatbot/history.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* Chatbot History
|
||||
* GET /v1/chatbot/history
|
||||
* Returns user's recent chatbot conversations.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$pagination = paginate_params(20, 50);
|
||||
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) FROM chatbot_history WHERE user_id = ?");
|
||||
$countStmt->execute([$decoded['user_id']]);
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, question, answer, created_at
|
||||
FROM chatbot_history
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||
");
|
||||
$stmt->execute([$decoded['user_id']]);
|
||||
|
||||
json_paginated($stmt->fetchAll(), $total, $pagination);
|
||||
@@ -61,5 +61,5 @@ try {
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("JoFotara Connection Error: " . $e->getMessage());
|
||||
json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@ try {
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$db->rollBack();
|
||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
||||
error_log("[companies/create] Error: " . $e->getMessage());
|
||||
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
|
||||
}
|
||||
|
||||
@@ -64,5 +64,5 @@ try {
|
||||
json_success($companies);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('SQL Error in Companies List: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'companies/index');
|
||||
}
|
||||
|
||||
@@ -76,5 +76,5 @@ try {
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("AI Usage Stats Error: " . $e->getMessage() . " | " . $e->getTraceAsString());
|
||||
json_error('خطأ في جلب إحصائيات AI: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'dashboard/ai_usage', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.');
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ try {
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if (isset($db)) $db->rollBack();
|
||||
json_error('فشل معالجة ملف الاكسل: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'excel/import', 'فشل معالجة ملف الإكسل.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
15
app/modules_app/gamification/profile.php
Normal file
15
app/modules_app/gamification/profile.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* Gamification Profile
|
||||
* GET /v1/gamification/profile
|
||||
* Returns user's points, level, badges, and progress.
|
||||
*/
|
||||
|
||||
use App\Services\GamificationService;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
|
||||
$profile = GamificationService::getProfile($decoded['user_id'], $decoded['tenant_id']);
|
||||
|
||||
json_success($profile, 'ملفك التنافسي');
|
||||
@@ -123,8 +123,21 @@ try {
|
||||
'api_success' => $apiResponse['success'],
|
||||
], $decoded);
|
||||
|
||||
// Smart Notifications
|
||||
\App\Services\SmartNotifications::invoiceApproved(
|
||||
$invoice['tenant_id'], $invoice['uploaded_by'] ?? $decoded['user_id'],
|
||||
$id, $invoice['invoice_number'] ?? $id
|
||||
);
|
||||
\App\Services\SmartNotifications::checkQuotaWarning($invoice['tenant_id']);
|
||||
|
||||
// Gamification
|
||||
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'invoice_approved');
|
||||
if ($apiResponse['success'] ?? false) {
|
||||
\App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'jofotara_submitted');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
error_log("JoFotara Approve Error: " . $e->getMessage());
|
||||
json_error('خطأ غير متوقع: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'invoices/approve');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Invoices List Endpoint (Role-Based & Tenant-Aware)
|
||||
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
@@ -16,26 +16,17 @@ $userId = $decoded['user_id'];
|
||||
$role = $decoded['role'];
|
||||
|
||||
try {
|
||||
// 2. Build Query based on Role
|
||||
$pagination = paginate_params(25, 100);
|
||||
|
||||
// 2. Build WHERE clause based on Role
|
||||
$where = '';
|
||||
$params = [];
|
||||
|
||||
if ($role === 'super_admin') {
|
||||
// Super Admin sees ALL invoices
|
||||
$stmt = $db->query("
|
||||
SELECT i.*, t.name as tenant_name, c.name as company_name
|
||||
FROM invoices i
|
||||
LEFT JOIN tenants t ON i.tenant_id = t.id
|
||||
LEFT JOIN companies c ON i.company_id = c.id
|
||||
ORDER BY i.created_at DESC
|
||||
");
|
||||
$where = '1=1';
|
||||
} elseif ($role === 'admin') {
|
||||
// Admin sees all invoices in THEIR tenant
|
||||
$stmt = $db->prepare("
|
||||
SELECT i.*, c.name as company_name
|
||||
FROM invoices i
|
||||
LEFT JOIN companies c ON i.company_id = c.id
|
||||
WHERE i.tenant_id = ?
|
||||
ORDER BY i.created_at DESC
|
||||
");
|
||||
$stmt->execute([$tenantId]);
|
||||
$where = 'i.tenant_id = ?';
|
||||
$params = [$tenantId];
|
||||
} else {
|
||||
// Accountant/Viewer: Filter by assigned companies
|
||||
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
||||
@@ -43,26 +34,58 @@ try {
|
||||
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (empty($assignedCompanyIds)) {
|
||||
json_success([]);
|
||||
json_paginated([], 0, $pagination);
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
||||
$stmt = $db->prepare("
|
||||
SELECT i.*, c.name as company_name
|
||||
FROM invoices i
|
||||
LEFT JOIN companies c ON i.company_id = c.id
|
||||
WHERE i.company_id IN ($placeholders)
|
||||
ORDER BY i.created_at DESC
|
||||
");
|
||||
$stmt->execute($assignedCompanyIds);
|
||||
$where = "i.company_id IN ($placeholders)";
|
||||
$params = $assignedCompanyIds;
|
||||
}
|
||||
|
||||
// Optional filters from query string
|
||||
$companyFilter = $_GET['company_id'] ?? null;
|
||||
$statusFilter = $_GET['status'] ?? null;
|
||||
$searchFilter = $_GET['search'] ?? null;
|
||||
|
||||
if ($companyFilter) {
|
||||
$where .= ' AND i.company_id = ?';
|
||||
$params[] = $companyFilter;
|
||||
}
|
||||
if ($statusFilter) {
|
||||
$where .= ' AND i.status = ?';
|
||||
$params[] = $statusFilter;
|
||||
}
|
||||
if ($searchFilter) {
|
||||
$where .= ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ?)';
|
||||
$params[] = "%$searchFilter%";
|
||||
$params[] = "%$searchFilter%";
|
||||
}
|
||||
|
||||
// 3. Count total
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) FROM invoices i WHERE $where");
|
||||
$countStmt->execute($params);
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
// 4. Fetch page
|
||||
$joinTenant = ($role === 'super_admin') ? 'LEFT JOIN tenants t ON i.tenant_id = t.id' : '';
|
||||
$selectTenant = ($role === 'super_admin') ? ', t.name as tenant_name' : '';
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT i.*{$selectTenant}, c.name as company_name
|
||||
FROM invoices i
|
||||
LEFT JOIN companies c ON i.company_id = c.id
|
||||
{$joinTenant}
|
||||
WHERE {$where}
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
|
||||
// 3. Decrypt sensitive fields for display (Robustly)
|
||||
// 5. Decrypt sensitive fields
|
||||
$dec = function($val) {
|
||||
if (empty($val)) return '';
|
||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
||||
$result = Encryption::decrypt((string)$val);
|
||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||
};
|
||||
|
||||
@@ -79,12 +102,8 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($invoices)) {
|
||||
error_log("INVOICES LIST: No invoices found for role: $role, tenant_id: $tenantId");
|
||||
}
|
||||
|
||||
json_success($invoices);
|
||||
json_paginated($invoices, $total, $pagination);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('SQL Error in Invoices List: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'invoices/index');
|
||||
}
|
||||
|
||||
@@ -148,6 +148,8 @@ if ($result['success']) {
|
||||
'jofotara_uuid' => $result['uuid'],
|
||||
], $decoded);
|
||||
|
||||
\App\Services\SmartNotifications::jofotaraSuccess($tenantId, $userId, $invoiceId, $result['uuid']);
|
||||
|
||||
json_success([
|
||||
'uuid' => $result['uuid'],
|
||||
'qr_code' => $qrBase64,
|
||||
@@ -158,5 +160,7 @@ if ($result['success']) {
|
||||
'error' => $result['error'] ?? 'Unknown',
|
||||
], $decoded);
|
||||
|
||||
\App\Services\SmartNotifications::jofotaraRejected($tenantId, $userId, $invoiceId, $result['error'] ?? 'خطأ غير محدد');
|
||||
|
||||
json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422);
|
||||
}
|
||||
|
||||
@@ -112,5 +112,5 @@ try {
|
||||
} catch (\Exception $e) {
|
||||
$db->rollBack();
|
||||
error_log("Invoice Update Error: " . $e->getMessage());
|
||||
json_error('فشل تحديث الفاتورة: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'invoices/update', 'فشل تحديث الفاتورة.');
|
||||
}
|
||||
|
||||
@@ -62,11 +62,12 @@ try {
|
||||
|
||||
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0777, true)) {
|
||||
json_error('فشل في إنشاء مجلد التخزين: ' . $dir, 500);
|
||||
if (!mkdir($dir, 0755, true)) {
|
||||
error_log('Failed to create storage directory: ' . $dir);
|
||||
json_error('فشل في تجهيز مساحة التخزين', 500);
|
||||
exit;
|
||||
}
|
||||
chmod($dir, 0777);
|
||||
chmod($dir, 0755);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +199,8 @@ try {
|
||||
|
||||
// --- INCREMENT QUOTA ---
|
||||
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
||||
\App\Services\SmartNotifications::checkQuotaWarning($tenantId);
|
||||
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
|
||||
// -----------------------
|
||||
|
||||
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
|
||||
@@ -207,14 +210,14 @@ try {
|
||||
if (isset($db) && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
error_log("Database Error: " . $e->getMessage());
|
||||
json_error('حدث خطأ في قاعدة البيانات: ' . $e->getMessage(), 500);
|
||||
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
if (isset($db) && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
error_log("Critical Error: " . $e->getMessage() . " on line " . $e->getLine());
|
||||
json_error('خطأ برمجي حرج: ' . $e->getMessage() . ' في السطر ' . $e->getLine(), 500);
|
||||
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
|
||||
exit;
|
||||
}
|
||||
74
app/modules_app/marketplace/listings.php
Normal file
74
app/modules_app/marketplace/listings.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* Marketplace — Accountant Directory & Service Listings
|
||||
* GET /v1/marketplace/listings
|
||||
* GET /v1/marketplace/listings?city=amman&specialty=tax
|
||||
*
|
||||
* Public directory where accounting offices can list their services
|
||||
* and businesses can find accountants.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Encryption;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$pagination = paginate_params(20, 50);
|
||||
|
||||
$city = $_GET['city'] ?? null;
|
||||
$specialty = $_GET['specialty'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
|
||||
$where = "ml.is_active = 1";
|
||||
$params = [];
|
||||
|
||||
if ($city) {
|
||||
$where .= " AND ml.city = ?";
|
||||
$params[] = $city;
|
||||
}
|
||||
if ($specialty) {
|
||||
$where .= " AND ml.specialty = ?";
|
||||
$params[] = $specialty;
|
||||
}
|
||||
if ($search) {
|
||||
$where .= " AND (ml.office_name LIKE ? OR ml.description LIKE ?)";
|
||||
$params[] = "%{$search}%";
|
||||
$params[] = "%{$search}%";
|
||||
}
|
||||
|
||||
try {
|
||||
// Count
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) FROM marketplace_listings ml WHERE {$where}");
|
||||
$countStmt->execute($params);
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
// Fetch
|
||||
$stmt = $db->prepare("
|
||||
SELECT ml.*, t.name as tenant_name
|
||||
FROM marketplace_listings ml
|
||||
LEFT JOIN tenants t ON ml.tenant_id = t.id
|
||||
WHERE {$where}
|
||||
ORDER BY ml.is_featured DESC, ml.rating DESC, ml.created_at DESC
|
||||
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$listings = $stmt->fetchAll();
|
||||
|
||||
// Decrypt names
|
||||
foreach ($listings as &$l) {
|
||||
if (!empty($l['tenant_name'])) {
|
||||
$dec = Encryption::decrypt($l['tenant_name']);
|
||||
$l['tenant_name'] = ($dec !== false && $dec !== null) ? $dec : $l['tenant_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$cities = ['amman' => 'عمّان', 'irbid' => 'إربد', 'zarqa' => 'الزرقاء', 'aqaba' => 'العقبة', 'salt' => 'السلط', 'madaba' => 'مأدبا', 'karak' => 'الكرك', 'other' => 'أخرى'];
|
||||
$specialties = ['tax' => 'ضرائب', 'audit' => 'تدقيق', 'bookkeeping' => 'مسك دفاتر', 'payroll' => 'رواتب', 'consulting' => 'استشارات', 'general' => 'عام'];
|
||||
|
||||
json_paginated($listings, $total, $pagination, 'سوق المحاسبين');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
safe_error($e, 'marketplace/listings', 'حدث خطأ في تحميل القوائم.');
|
||||
}
|
||||
63
app/modules_app/marketplace/my_listing.php
Normal file
63
app/modules_app/marketplace/my_listing.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* Marketplace — Create/Update My Listing
|
||||
* POST /v1/marketplace/my-listing
|
||||
* Body: { "office_name": "...", "city": "amman", "specialty": "tax", "description": "...", "phone": "...", "email": "..." }
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Encryption;
|
||||
use App\Core\Validator;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
use App\Middleware\RoleMiddleware;
|
||||
|
||||
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||
$db = Database::getInstance();
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
$data = input();
|
||||
|
||||
$errors = Validator::validate($data, [
|
||||
'office_name' => 'required',
|
||||
'city' => 'required',
|
||||
'specialty' => 'required',
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
json_error('بيانات ناقصة', 422, $errors);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if listing exists
|
||||
$existing = $db->prepare("SELECT id FROM marketplace_listings WHERE tenant_id = ? LIMIT 1");
|
||||
$existing->execute([$tenantId]);
|
||||
$row = $existing->fetch();
|
||||
|
||||
if ($row) {
|
||||
// Update
|
||||
$db->prepare("
|
||||
UPDATE marketplace_listings SET
|
||||
office_name = ?, city = ?, specialty = ?, description = ?,
|
||||
contact_phone = ?, contact_email = ?, updated_at = NOW()
|
||||
WHERE tenant_id = ?
|
||||
")->execute([
|
||||
$data['office_name'], $data['city'], $data['specialty'],
|
||||
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '',
|
||||
$tenantId
|
||||
]);
|
||||
json_success(['id' => $row['id']], 'تم تحديث القائمة بنجاح');
|
||||
} else {
|
||||
// Create
|
||||
$id = Database::generateUuid();
|
||||
$db->prepare("
|
||||
INSERT INTO marketplace_listings (id, tenant_id, office_name, city, specialty, description, contact_phone, contact_email, is_active, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW())
|
||||
")->execute([
|
||||
$id, $tenantId, $data['office_name'], $data['city'], $data['specialty'],
|
||||
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? ''
|
||||
]);
|
||||
json_success(['id' => $id], 'تم إضافة مكتبك للسوق بنجاح! 🎉');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
safe_error($e, 'marketplace/my-listing', 'حدث خطأ في حفظ القائمة.');
|
||||
}
|
||||
86
app/modules_app/referral/apply.php
Normal file
86
app/modules_app/referral/apply.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* Apply Referral Code During Registration
|
||||
* POST /v1/referral/apply
|
||||
* Body: { "referral_code": "MSQ-ABC123" }
|
||||
*
|
||||
* Called during registration to link a new user to their referrer.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$data = Security::sanitize(input());
|
||||
$code = $data['referral_code'] ?? null;
|
||||
|
||||
if (!$code) {
|
||||
json_error('رمز الإحالة مطلوب', 422);
|
||||
}
|
||||
|
||||
$userId = $decoded['user_id'];
|
||||
$tenantId = $decoded['tenant_id'] ?? null;
|
||||
|
||||
try {
|
||||
// 1. Validate the referral code
|
||||
$stmt = $db->prepare("SELECT * FROM referral_codes WHERE code = ? LIMIT 1");
|
||||
$stmt->execute([$code]);
|
||||
$referralCode = $stmt->fetch();
|
||||
|
||||
if (!$referralCode) {
|
||||
json_error('رمز الإحالة غير صالح', 404);
|
||||
}
|
||||
|
||||
// Prevent self-referral
|
||||
if ($referralCode['user_id'] === $userId) {
|
||||
json_error('لا يمكنك استخدام رمز الإحالة الخاص بك', 400);
|
||||
}
|
||||
|
||||
// Check if user already used a referral
|
||||
$checkStmt = $db->prepare("SELECT id FROM referrals WHERE referred_id = ? LIMIT 1");
|
||||
$checkStmt->execute([$userId]);
|
||||
if ($checkStmt->fetch()) {
|
||||
json_error('لقد استخدمت رمز إحالة مسبقاً', 409);
|
||||
}
|
||||
|
||||
// 2. Create the referral record
|
||||
$db->beginTransaction();
|
||||
|
||||
$referralId = \App\Core\Database::generateUuid();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO referrals (id, referrer_id, referred_id, referral_code_id, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'registered', NOW())
|
||||
");
|
||||
$stmt->execute([$referralId, $referralCode['user_id'], $userId, $referralCode['id']]);
|
||||
|
||||
// 3. Notify the referrer
|
||||
try {
|
||||
$notifStmt = $db->prepare("
|
||||
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
|
||||
VALUES (UUID(), ?, ?, 'referral', '🎉 إحالة جديدة!', 'شخص جديد انضم باستخدام رمز إحالتك', ?, NOW())
|
||||
");
|
||||
$notifStmt->execute([
|
||||
$referralCode['tenant_id'],
|
||||
$referralCode['user_id'],
|
||||
json_encode(['referral_id' => $referralId, 'code' => $code])
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Don't fail the whole operation if notification fails
|
||||
error_log("[referral/apply] Notification failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
|
||||
json_success([
|
||||
'referral_id' => $referralId,
|
||||
'referrer_code' => $code,
|
||||
'status' => 'registered',
|
||||
], 'تم تطبيق رمز الإحالة بنجاح! 🎉');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
safe_error($e, 'referral/apply', 'حدث خطأ في تطبيق رمز الإحالة.');
|
||||
}
|
||||
@@ -93,5 +93,5 @@ try {
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log("Referral error: " . $e->getMessage() . " | Trace: " . $e->getTraceAsString());
|
||||
json_error('حدث خطأ في نظام الإحالة: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'referral/my_code', 'حدث خطأ في نظام الإحالة.');
|
||||
}
|
||||
|
||||
142
app/modules_app/reports/company_health.php
Normal file
142
app/modules_app/reports/company_health.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* AI Company Health Report
|
||||
* GET /v1/reports/company-health?company_id=xxx
|
||||
*
|
||||
* Generates an AI-powered financial health analysis using invoice data.
|
||||
* Returns insights, warnings, and recommendations in Arabic.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Encryption;
|
||||
use App\Core\AI;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
$role = $decoded['role'];
|
||||
$companyId = $_GET['company_id'] ?? null;
|
||||
|
||||
if (!$companyId) {
|
||||
json_error('معرّف الشركة مطلوب', 422);
|
||||
}
|
||||
|
||||
// Verify access
|
||||
$accessQuery = ($role === 'super_admin')
|
||||
? "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND deleted_at IS NULL"
|
||||
: "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL";
|
||||
|
||||
$accessParams = ($role === 'super_admin') ? [$companyId] : [$companyId, $tenantId];
|
||||
$stmt = $db->prepare($accessQuery);
|
||||
$stmt->execute($accessParams);
|
||||
$company = $stmt->fetch();
|
||||
|
||||
if (!$company) {
|
||||
json_error('الشركة غير موجودة أو ليس لديك صلاحية', 404);
|
||||
}
|
||||
|
||||
$companyName = Encryption::decrypt($company['name']) ?: $company['name'];
|
||||
|
||||
try {
|
||||
// 1. Gather last 3 months of data
|
||||
$months = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$m = date('m', strtotime("-{$i} months"));
|
||||
$y = date('Y', strtotime("-{$i} months"));
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_invoices,
|
||||
COALESCE(SUM(grand_total), 0) as revenue,
|
||||
COALESCE(SUM(tax_amount), 0) as tax,
|
||||
COALESCE(SUM(discount_total), 0) as discounts,
|
||||
COALESCE(AVG(grand_total), 0) as avg_invoice,
|
||||
SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count,
|
||||
SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count
|
||||
FROM invoices
|
||||
WHERE company_id = ? AND MONTH(created_at) = ? AND YEAR(created_at) = ?
|
||||
");
|
||||
$stmt->execute([$companyId, $m, $y]);
|
||||
$data = $stmt->fetch();
|
||||
$data['month'] = (int)$m;
|
||||
$data['year'] = (int)$y;
|
||||
$months[] = $data;
|
||||
}
|
||||
|
||||
// 2. Pending invoices count
|
||||
$pendingStmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE company_id = ? AND status = 'extracted'");
|
||||
$pendingStmt->execute([$companyId]);
|
||||
$pendingCount = (int)$pendingStmt->fetchColumn();
|
||||
|
||||
// 3. Build AI prompt
|
||||
$dataJson = json_encode([
|
||||
'company_name' => $companyName,
|
||||
'tin' => $company['tax_identification_number'],
|
||||
'monthly_data' => $months,
|
||||
'pending_invoices' => $pendingCount,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
أنت محلل مالي خبير. حلل البيانات التالية لشركة وأعطِ تقريراً مختصراً بالعربية.
|
||||
|
||||
البيانات:
|
||||
{$dataJson}
|
||||
|
||||
أعد الرد بصيغة JSON فقط بدون أي نص إضافي:
|
||||
{
|
||||
"health_score": (رقم من 1 إلى 10),
|
||||
"health_label": ("ممتاز" أو "جيد" أو "متوسط" أو "يحتاج انتباه"),
|
||||
"summary": "ملخص من سطرين عن الحالة المالية",
|
||||
"insights": ["ملاحظة 1", "ملاحظة 2", "ملاحظة 3"],
|
||||
"warnings": ["تحذير إن وجد"],
|
||||
"recommendations": ["توصية 1", "توصية 2"]
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$aiResponse = AI::ask($prompt, $tenantId);
|
||||
|
||||
// Parse AI response
|
||||
$report = null;
|
||||
if ($aiResponse) {
|
||||
// Extract JSON from response
|
||||
$cleaned = preg_replace('/```json?\s*|```/', '', $aiResponse);
|
||||
$report = json_decode(trim($cleaned), true);
|
||||
}
|
||||
|
||||
// Fallback if AI fails
|
||||
if (!$report) {
|
||||
$currentMonth = $months[0] ?? [];
|
||||
$prevMonth = $months[1] ?? [];
|
||||
$score = 5;
|
||||
|
||||
if (($currentMonth['total_invoices'] ?? 0) > 0) $score += 2;
|
||||
if (($currentMonth['submitted_count'] ?? 0) > 0) $score += 1;
|
||||
if ($pendingCount === 0) $score += 1;
|
||||
if (($currentMonth['revenue'] ?? 0) > ($prevMonth['revenue'] ?? 0)) $score += 1;
|
||||
|
||||
$report = [
|
||||
'health_score' => min(10, $score),
|
||||
'health_label' => $score >= 8 ? 'ممتاز' : ($score >= 6 ? 'جيد' : 'متوسط'),
|
||||
'summary' => 'تقرير مبني على البيانات المتوفرة بدون تحليل AI.',
|
||||
'insights' => ['عدد الفواتير: ' . ($currentMonth['total_invoices'] ?? 0)],
|
||||
'warnings' => $pendingCount > 0 ? ["يوجد {$pendingCount} فاتورة بانتظار المراجعة"] : [],
|
||||
'recommendations' => ['تأكد من إرسال جميع الفواتير المعتمدة لجوفوترا'],
|
||||
];
|
||||
}
|
||||
|
||||
json_success([
|
||||
'company_id' => $companyId,
|
||||
'company_name' => $companyName,
|
||||
'report' => $report,
|
||||
'data' => [
|
||||
'monthly_summary' => $months,
|
||||
'pending_count' => $pendingCount,
|
||||
],
|
||||
'generated_at' => date('c'),
|
||||
], 'تقرير صحة الشركة');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
safe_error($e, 'reports/company-health', 'حدث خطأ في إنشاء التقرير.');
|
||||
}
|
||||
188
app/modules_app/sms/receive.php
Normal file
188
app/modules_app/sms/receive.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
/**
|
||||
* SMS Bank Integration — Receive & Auto-Match Payments
|
||||
* POST /v1/sms/receive
|
||||
*
|
||||
* Flow:
|
||||
* 1. Android SMS Bot intercepts bank/wallet SMS
|
||||
* 2. Sends it here: { "sender": "BANK_NAME", "message": "تم تحويل 45 دينار..." }
|
||||
* 3. We save it in raw_sms_log with status "pending"
|
||||
* 4. We immediately try to match it against pending payment requests
|
||||
* 5. If matched → confirm payment → update subscription → notify user
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\AuditLogger;
|
||||
|
||||
// Auth: Verify webhook secret (shared between Android bot and server)
|
||||
$webhookSecret = env('SMS_WEBHOOK_SECRET', '');
|
||||
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? $_SERVER['HTTP_X_SMS_SECRET'] ?? '';
|
||||
|
||||
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$json_data = file_get_contents('php://input');
|
||||
$data = json_decode($json_data, true);
|
||||
|
||||
if (!$data || empty($data['sender']) || empty($data['message'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'بيانات غير مكتملة. يجب إرسال sender و message.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sender = trim($data['sender']);
|
||||
$message = trim($data['message']);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
try {
|
||||
// 1. Save raw SMS log
|
||||
$smsId = \App\Core\Database::generateUuid();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO raw_sms_log (id, sender, message_body, status, received_at)
|
||||
VALUES (?, ?, ?, 'pending', NOW())
|
||||
");
|
||||
$stmt->execute([$smsId, $sender, $message]);
|
||||
|
||||
// 2. Try to auto-match with pending payments
|
||||
$matchResult = matchPayment($db, $smsId, $sender, $message);
|
||||
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'SMS received and processed.',
|
||||
'matched' => $matchResult['matched'],
|
||||
'details' => $matchResult['details'] ?? null,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("[sms/receive] Error: " . $e->getMessage());
|
||||
http_response_code(200); // Return 200 so bot doesn't retry
|
||||
echo json_encode(['status' => 'error', 'message' => 'خطأ داخلي في المعالجة.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match the incoming SMS with a pending payment request.
|
||||
*
|
||||
* Matching logic:
|
||||
* 1. Extract reference number from SMS (formats: MSQ-XXXX, REF-XXXX, or plain digits)
|
||||
* 2. Extract amount from SMS
|
||||
* 3. Find pending payment request matching reference OR amount
|
||||
* 4. If matched → confirm payment → activate/extend subscription
|
||||
*/
|
||||
function matchPayment(\PDO $db, string $smsId, string $sender, string $message): array
|
||||
{
|
||||
// Extract reference number (MSQ-XXXX pattern or any 6+ digit number)
|
||||
$reference = null;
|
||||
if (preg_match('/MSQ-([A-Z0-9]{4,10})/i', $message, $m)) {
|
||||
$reference = 'MSQ-' . strtoupper($m[1]);
|
||||
} elseif (preg_match('/REF[:\s-]*([A-Z0-9]{4,12})/i', $message, $m)) {
|
||||
$reference = $m[1];
|
||||
}
|
||||
|
||||
// Extract amount (Arabic or English digits)
|
||||
$amount = null;
|
||||
$msgNormalized = strtr($message, ['٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']);
|
||||
if (preg_match('/(\d+[\.,]?\d{0,3})\s*(دينار|JOD|JD)/iu', $msgNormalized, $m)) {
|
||||
$amount = (float)str_replace(',', '.', $m[1]);
|
||||
} elseif (preg_match('/(\d+[\.,]\d{2})/', $msgNormalized, $m)) {
|
||||
$amount = (float)str_replace(',', '.', $m[1]);
|
||||
}
|
||||
|
||||
if (!$reference && !$amount) {
|
||||
// Can't match — mark SMS as unmatched
|
||||
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', processed_at = NOW() WHERE id = ?")->execute([$smsId]);
|
||||
return ['matched' => false, 'details' => 'لم يتم العثور على مرجع أو مبلغ في الرسالة'];
|
||||
}
|
||||
|
||||
// Search for pending payment request
|
||||
$where = "pr.status = 'pending'";
|
||||
$params = [];
|
||||
|
||||
if ($reference) {
|
||||
$where .= " AND pr.reference_number = ?";
|
||||
$params[] = $reference;
|
||||
}
|
||||
if ($amount) {
|
||||
$where .= " AND pr.amount = ?";
|
||||
$params[] = $amount;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT pr.*, t.name as tenant_name
|
||||
FROM payment_requests pr
|
||||
LEFT JOIN tenants t ON pr.tenant_id = t.id
|
||||
WHERE {$where}
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$payment = $stmt->fetch();
|
||||
|
||||
if (!$payment) {
|
||||
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?")
|
||||
->execute([$reference, $amount, $smsId]);
|
||||
return ['matched' => false, 'details' => "مرجع: {$reference}, مبلغ: {$amount} — لم يتطابق مع أي طلب دفع"];
|
||||
}
|
||||
|
||||
// MATCH FOUND — Process payment
|
||||
$db->beginTransaction();
|
||||
|
||||
try {
|
||||
// 1. Update payment request → confirmed
|
||||
$db->prepare("
|
||||
UPDATE payment_requests SET status = 'confirmed', sms_log_id = ?, confirmed_at = NOW() WHERE id = ?
|
||||
")->execute([$smsId, $payment['id']]);
|
||||
|
||||
// 2. Update SMS log → matched
|
||||
$db->prepare("
|
||||
UPDATE raw_sms_log SET status = 'matched', payment_request_id = ?, extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?
|
||||
")->execute([$payment['id'], $reference, $amount, $smsId]);
|
||||
|
||||
// 3. Activate/extend subscription
|
||||
$planMonths = (int)($payment['plan_months'] ?? 1);
|
||||
$db->prepare("
|
||||
UPDATE subscriptions
|
||||
SET is_active = 1,
|
||||
started_at = COALESCE(started_at, NOW()),
|
||||
expires_at = DATE_ADD(COALESCE(expires_at, NOW()), INTERVAL ? MONTH),
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = ?
|
||||
")->execute([$planMonths, $payment['tenant_id']]);
|
||||
|
||||
// 4. Notify user
|
||||
\App\Services\SmartNotifications::send(
|
||||
$payment['tenant_id'],
|
||||
$payment['user_id'] ?? '',
|
||||
'payment_confirmed',
|
||||
'✅ تم تأكيد الدفع!',
|
||||
"تم تأكيد دفعة بقيمة {$payment['amount']} دينار. اشتراكك فعّال الآن.",
|
||||
['payment_id' => $payment['id'], 'amount' => $payment['amount']]
|
||||
);
|
||||
|
||||
// 5. Audit log
|
||||
AuditLogger::log('payment.auto_confirmed', 'payment', $payment['id'], null, [
|
||||
'sms_id' => $smsId,
|
||||
'sender' => $sender,
|
||||
'reference' => $reference,
|
||||
'amount' => $amount,
|
||||
], ['user_id' => 'system', 'tenant_id' => $payment['tenant_id'], 'role' => 'system']);
|
||||
|
||||
$db->commit();
|
||||
|
||||
return [
|
||||
'matched' => true,
|
||||
'details' => "تم مطابقة الدفعة: {$payment['amount']} دينار — الاشتراك مُفعّل",
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$db->rollBack();
|
||||
error_log("[sms/match] Failed: " . $e->getMessage());
|
||||
return ['matched' => false, 'details' => 'خطأ أثناء تأكيد الدفعة'];
|
||||
}
|
||||
}
|
||||
@@ -91,5 +91,5 @@ try {
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
error_log("Subscription Assign Error: " . $e->getMessage());
|
||||
json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
|
||||
}
|
||||
|
||||
@@ -78,6 +78,6 @@ try {
|
||||
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
|
||||
} catch (\Exception $e) {
|
||||
$db->rollBack();
|
||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.');
|
||||
}
|
||||
|
||||
|
||||
@@ -42,5 +42,5 @@ try {
|
||||
json_success($tenants);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('SQL Error in Tenants List: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'tenants/index');
|
||||
}
|
||||
|
||||
@@ -56,5 +56,5 @@ try {
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('Stats Error: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'tenants/stats');
|
||||
}
|
||||
|
||||
@@ -59,5 +59,5 @@ try {
|
||||
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.');
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ $errors = Validator::validate($data, [
|
||||
'name' => 'required',
|
||||
'email' => 'required|email',
|
||||
'phone' => 'required',
|
||||
'password' => 'required',
|
||||
'password' => 'required|strong_password',
|
||||
'role' => 'required'
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Users List Endpoint (Role-Based & Tenant-Aware)
|
||||
* Users List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
@@ -14,37 +14,58 @@ $db = Database::getInstance();
|
||||
$role = $decoded['role'];
|
||||
$tenantId = $decoded['tenant_id'] ?? null;
|
||||
|
||||
if ($role !== 'super_admin' && $role !== 'admin') {
|
||||
json_error('Unauthorized', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Build Query based on Role
|
||||
$pagination = paginate_params(25, 100);
|
||||
|
||||
// 2. Build WHERE clause based on Role
|
||||
$where = '';
|
||||
$params = [];
|
||||
|
||||
if ($role === 'super_admin') {
|
||||
// Super Admin sees ALL users from ALL tenants
|
||||
$stmt = $db->query("
|
||||
SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON u.tenant_id = t.id
|
||||
ORDER BY u.created_at DESC
|
||||
");
|
||||
} elseif ($role === 'admin') {
|
||||
// Admin sees only users in THEIR tenant (Accounting Office)
|
||||
$stmt = $db->prepare("
|
||||
SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON u.tenant_id = t.id
|
||||
WHERE u.tenant_id = ?
|
||||
ORDER BY u.created_at DESC
|
||||
");
|
||||
$stmt->execute([$tenantId]);
|
||||
$where = '1=1';
|
||||
} else {
|
||||
// Other roles shouldn't see user list
|
||||
json_error('Unauthorized', 403);
|
||||
$where = 'u.tenant_id = ?';
|
||||
$params = [$tenantId];
|
||||
}
|
||||
|
||||
// Optional filters
|
||||
$roleFilter = $_GET['role'] ?? null;
|
||||
$activeFilter = $_GET['is_active'] ?? null;
|
||||
|
||||
if ($roleFilter) {
|
||||
$where .= ' AND u.role = ?';
|
||||
$params[] = $roleFilter;
|
||||
}
|
||||
if ($activeFilter !== null && $activeFilter !== '') {
|
||||
$where .= ' AND u.is_active = ?';
|
||||
$params[] = (int)$activeFilter;
|
||||
}
|
||||
|
||||
// 3. Count total
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) FROM users u WHERE $where");
|
||||
$countStmt->execute($params);
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
// 4. Fetch page
|
||||
$stmt = $db->prepare("
|
||||
SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON u.tenant_id = t.id
|
||||
WHERE $where
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
// 3. Decrypt data and format
|
||||
// 5. Decrypt data
|
||||
$dec = function($val) {
|
||||
if (empty($val)) return '';
|
||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
||||
$result = Encryption::decrypt((string)$val);
|
||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||
};
|
||||
|
||||
@@ -54,18 +75,13 @@ try {
|
||||
if (!empty($user['phone'])) {
|
||||
$user['phone'] = $dec($user['phone']);
|
||||
}
|
||||
|
||||
if (!empty($user['tenant_name'])) {
|
||||
$user['tenant_name'] = $dec($user['tenant_name']);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($users)) {
|
||||
error_log("USERS LIST: No users found for role: $role, tenant_id: $tenantId");
|
||||
}
|
||||
|
||||
json_success($users);
|
||||
json_paginated($users, $total, $pagination);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('SQL Error in Users List: ' . $e->getMessage(), 500);
|
||||
safe_error($e, 'users/index');
|
||||
}
|
||||
|
||||
35
app/modules_app/whatsapp/link_code.php
Normal file
35
app/modules_app/whatsapp/link_code.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* Generate WhatsApp Link Code
|
||||
* GET /v1/whatsapp/link-code
|
||||
*
|
||||
* Generates a one-time code that the user sends to the WhatsApp bot
|
||||
* to link their phone number with their Musadaq account.
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$userId = $decoded['user_id'];
|
||||
|
||||
try {
|
||||
// Generate a short, easy-to-type code
|
||||
$code = strtoupper(substr(md5($userId . time() . random_int(1000, 9999)), 0, 6));
|
||||
|
||||
// Save the code (expires in 10 minutes)
|
||||
$stmt = $db->prepare("UPDATE users SET whatsapp_link_code = ? WHERE id = ?");
|
||||
$stmt->execute([$code, $userId]);
|
||||
|
||||
json_success([
|
||||
'code' => $code,
|
||||
'expires_in' => 600, // 10 minutes
|
||||
'instruction' => "أرسل هذه الرسالة للرقم التالي على واتساب:\n\nربط {$code}",
|
||||
'bot_number' => env('WHATSAPP_BOT_NUMBER', '+962XXXXXXXXX'),
|
||||
], 'تم إنشاء كود الربط');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
safe_error($e, 'whatsapp/link-code', 'حدث خطأ في إنشاء كود الربط.');
|
||||
}
|
||||
259
app/modules_app/whatsapp/webhook.php
Normal file
259
app/modules_app/whatsapp/webhook.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
/**
|
||||
* WhatsApp Bot Webhook
|
||||
* POST /v1/whatsapp/webhook
|
||||
*
|
||||
* Receives incoming WhatsApp messages (text + images) via the proxy bot.
|
||||
* Flow: User sends invoice image → Bot processes via AI → Returns extracted data.
|
||||
*
|
||||
* Supported commands:
|
||||
* - Image/Document: Extracts invoice data via AI
|
||||
* - "ربط [CODE]": Links WhatsApp number to Musadaq account
|
||||
* - "حالتي" or "status": Returns account summary
|
||||
* - "مساعدة" or "help": Returns command list
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\AI;
|
||||
use App\Core\Encryption;
|
||||
use App\Core\AuditLogger;
|
||||
|
||||
// No auth middleware — this is a webhook from the bot proxy
|
||||
// Verify webhook secret instead
|
||||
$webhookSecret = env('WHATSAPP_WEBHOOK_SECRET', '');
|
||||
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
|
||||
|
||||
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||
json_error('Unauthorized webhook', 401);
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$body) {
|
||||
json_error('Invalid payload', 400);
|
||||
}
|
||||
|
||||
$from = $body['from'] ?? ''; // Phone number (962XXXXXXXXX)
|
||||
$text = $body['message']['text'] ?? '';
|
||||
$imageUrl = $body['message']['image_url'] ?? null;
|
||||
$imageData = $body['message']['image_base64'] ?? null;
|
||||
$mimeType = $body['message']['mime_type'] ?? 'image/jpeg';
|
||||
|
||||
if (empty($from)) {
|
||||
json_error('Missing sender number', 400);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$wa = new \App\Services\WhatsAppProxyService();
|
||||
|
||||
try {
|
||||
// 1. Look up linked account by phone hash
|
||||
$phoneClean = preg_replace('/[^0-9+]/', '', $from);
|
||||
$phoneHash = hash('sha256', $phoneClean);
|
||||
|
||||
$stmt = $db->prepare("SELECT u.id, u.tenant_id, u.name, u.role FROM users u WHERE u.phone_hash = ? AND u.is_active = 1 LIMIT 1");
|
||||
$stmt->execute([$phoneHash]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
// 2. Handle commands
|
||||
$textLower = mb_strtolower(trim($text));
|
||||
|
||||
// === LINK COMMAND ===
|
||||
if (str_starts_with($textLower, 'ربط ') || str_starts_with($textLower, 'link ')) {
|
||||
$code = trim(str_replace(['ربط', 'link'], '', $text));
|
||||
handleLinkCommand($db, $wa, $from, $phoneHash, $code);
|
||||
exit;
|
||||
}
|
||||
|
||||
// === HELP COMMAND ===
|
||||
if (in_array($textLower, ['مساعدة', 'help', '؟', '?'])) {
|
||||
$wa->sendMessage($from, "🤖 *أوامر مُصادَق:*\n\n"
|
||||
. "📸 أرسل صورة فاتورة → نستخرج البيانات بالـ AI\n"
|
||||
. "🔗 ربط [الكود] → لربط رقمك بحسابك\n"
|
||||
. "📊 حالتي → ملخص حسابك\n"
|
||||
. "❓ مساعدة → هذه الرسالة\n\n"
|
||||
. "للتسجيل: musadaq.intaleqapp.com");
|
||||
json_success(null, 'Help sent');
|
||||
exit;
|
||||
}
|
||||
|
||||
// === ACCOUNT NOT LINKED ===
|
||||
if (!$user) {
|
||||
$wa->sendMessage($from, "👋 مرحباً!\n\n"
|
||||
. "رقمك غير مربوط بحساب مُصادَق.\n"
|
||||
. "لربط حسابك، أرسل: *ربط [الكود]*\n\n"
|
||||
. "للحصول على الكود، افتح تطبيق مُصادَق → الإعدادات → ربط واتساب.\n\n"
|
||||
. "أو سجّل حساب جديد: musadaq.intaleqapp.com");
|
||||
json_success(null, 'Unlinked user guided');
|
||||
exit;
|
||||
}
|
||||
|
||||
$userName = Encryption::decrypt($user['name']) ?: 'المستخدم';
|
||||
|
||||
// === STATUS COMMAND ===
|
||||
if (in_array($textLower, ['حالتي', 'status', 'حالة'])) {
|
||||
handleStatusCommand($db, $wa, $from, $user, $userName);
|
||||
exit;
|
||||
}
|
||||
|
||||
// === IMAGE/INVOICE PROCESSING ===
|
||||
if ($imageData || $imageUrl) {
|
||||
handleInvoiceImage($db, $wa, $from, $user, $userName, $imageData, $imageUrl, $mimeType);
|
||||
exit;
|
||||
}
|
||||
|
||||
// === DEFAULT: Unknown text ===
|
||||
$wa->sendMessage($from, "مرحباً {$userName} 👋\n\n"
|
||||
. "لم أفهم طلبك. يمكنك:\n"
|
||||
. "📸 إرسال صورة فاتورة لاستخراج البيانات\n"
|
||||
. "📊 كتابة *حالتي* لملخص حسابك\n"
|
||||
. "❓ كتابة *مساعدة* لقائمة الأوامر");
|
||||
|
||||
json_success(null, 'Default response sent');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log("[whatsapp/webhook] Error: " . $e->getMessage());
|
||||
try {
|
||||
$wa->sendMessage($from, "⚠️ حدث خطأ أثناء المعالجة. يرجى المحاولة مرة أخرى.");
|
||||
} catch (\Throwable $ignore) {}
|
||||
json_success(null, 'Error handled'); // Return 200 so the bot doesn't retry
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// HANDLER FUNCTIONS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function handleLinkCommand($db, $wa, string $from, string $phoneHash, string $code): void
|
||||
{
|
||||
if (empty($code)) {
|
||||
$wa->sendMessage($from, "❌ يرجى إرسال الكود. مثال: *ربط ABC123*");
|
||||
json_success(null, 'Empty code');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by link code
|
||||
$stmt = $db->prepare("SELECT id, tenant_id FROM users WHERE whatsapp_link_code = ? AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute([strtoupper(trim($code))]);
|
||||
$targetUser = $stmt->fetch();
|
||||
|
||||
if (!$targetUser) {
|
||||
$wa->sendMessage($from, "❌ الكود غير صحيح. تأكد من الكود في تطبيق مُصادَق → الإعدادات → ربط واتساب.");
|
||||
json_success(null, 'Invalid code');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update user's phone hash
|
||||
$updateStmt = $db->prepare("UPDATE users SET phone_hash = ?, whatsapp_linked = 1, whatsapp_link_code = NULL WHERE id = ?");
|
||||
$updateStmt->execute([$phoneHash, $targetUser['id']]);
|
||||
|
||||
$wa->sendMessage($from, "✅ تم ربط رقمك بحسابك بنجاح! 🎉\n\n"
|
||||
. "الآن يمكنك إرسال صور الفواتير مباشرة هنا وسنستخرج البيانات تلقائياً.");
|
||||
|
||||
json_success(null, 'Account linked');
|
||||
}
|
||||
|
||||
function handleStatusCommand($db, $wa, string $from, array $user, string $userName): void
|
||||
{
|
||||
$tenantId = $user['tenant_id'];
|
||||
|
||||
// Get stats
|
||||
$invoiceStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) as pending FROM invoices WHERE tenant_id = ?");
|
||||
$invoiceStmt->execute([$tenantId]);
|
||||
$stats = $invoiceStmt->fetch();
|
||||
|
||||
$subStmt = $db->prepare("SELECT plan_slug, invoices_used_this_month, max_invoices_per_month FROM subscriptions WHERE tenant_id = ?");
|
||||
$subStmt->execute([$tenantId]);
|
||||
$sub = $subStmt->fetch();
|
||||
|
||||
$plan = $sub['plan_slug'] ?? 'free';
|
||||
$used = $sub['invoices_used_this_month'] ?? 0;
|
||||
$max = $sub['max_invoices_per_month'] ?? 15;
|
||||
|
||||
$msg = "📊 *ملخص حسابك، {$userName}:*\n\n"
|
||||
. "📋 إجمالي الفواتير: {$stats['total']}\n"
|
||||
. "⏳ بانتظار المراجعة: {$stats['pending']}\n"
|
||||
. "📦 الباقة: {$plan}\n"
|
||||
. "🔢 الاستخدام: {$used}/{$max} فاتورة هذا الشهر\n\n"
|
||||
. "🌐 لوحة التحكم: musadaq.intaleqapp.com";
|
||||
|
||||
$wa->sendMessage($from, $msg);
|
||||
json_success(null, 'Status sent');
|
||||
}
|
||||
|
||||
function handleInvoiceImage($db, $wa, string $from, array $user, string $userName, ?string $imageData, ?string $imageUrl, string $mimeType): void
|
||||
{
|
||||
$wa->sendMessage($from, "📸 استلمت الصورة! جارٍ استخراج البيانات بالذكاء الاصطناعي... ⏳");
|
||||
|
||||
// Get image data
|
||||
if (!$imageData && $imageUrl) {
|
||||
$imageContent = @file_get_contents($imageUrl);
|
||||
if (!$imageContent) {
|
||||
$wa->sendMessage($from, "❌ فشل تحميل الصورة. يرجى إرسالها مرة أخرى.");
|
||||
json_success(null, 'Image download failed');
|
||||
return;
|
||||
}
|
||||
$imageData = base64_encode($imageContent);
|
||||
}
|
||||
|
||||
if (!$imageData) {
|
||||
$wa->sendMessage($from, "❌ لم أتمكن من قراءة الصورة.");
|
||||
json_success(null, 'No image data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run AI extraction
|
||||
$extracted = AI::extractInvoiceData($imageData, $mimeType);
|
||||
|
||||
if (!$extracted) {
|
||||
$wa->sendMessage($from, "⚠️ لم أتمكن من استخراج البيانات. تأكد أن الصورة واضحة وتحتوي على فاتورة.");
|
||||
json_success(null, 'AI extraction failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format response
|
||||
$supplierName = $extracted['supplier']['name'] ?? 'غير محدد';
|
||||
$invoiceNum = $extracted['invoice_number'] ?? '-';
|
||||
$invoiceDate = $extracted['invoice_date'] ?? '-';
|
||||
$subtotal = number_format((float)($extracted['subtotal'] ?? 0), 2);
|
||||
$tax = number_format((float)($extracted['tax_amount'] ?? 0), 2);
|
||||
$total = number_format((float)($extracted['grand_total'] ?? 0), 2);
|
||||
$linesCount = count($extracted['lines'] ?? []);
|
||||
|
||||
$msg = "✅ *تم استخراج بيانات الفاتورة:*\n\n"
|
||||
. "🏢 المورد: {$supplierName}\n"
|
||||
. "🔢 رقم الفاتورة: {$invoiceNum}\n"
|
||||
. "📅 التاريخ: {$invoiceDate}\n"
|
||||
. "📦 البنود: {$linesCount}\n"
|
||||
. "───────────────\n"
|
||||
. "💰 المبلغ قبل الضريبة: {$subtotal} دينار\n"
|
||||
. "🏛️ الضريبة: {$tax} دينار\n"
|
||||
. "📊 *الإجمالي: {$total} دينار*\n\n";
|
||||
|
||||
// Add warnings if any
|
||||
if (!empty($extracted['validation_warnings'])) {
|
||||
$msg .= "⚠️ *تحذيرات:*\n";
|
||||
foreach ($extracted['validation_warnings'] as $w) {
|
||||
$msg .= "• {$w}\n";
|
||||
}
|
||||
$msg .= "\n";
|
||||
}
|
||||
|
||||
$msg .= "💡 لحفظ هذه الفاتورة رسمياً، ارفعها من تطبيق مُصادَق.";
|
||||
|
||||
$wa->sendMessage($from, $msg);
|
||||
|
||||
// Log the interaction
|
||||
try {
|
||||
AuditLogger::log('whatsapp.invoice_extracted', 'whatsapp', null, null, [
|
||||
'from' => substr($from, 0, 6) . '****',
|
||||
'invoice_number' => $invoiceNum,
|
||||
'total' => $total,
|
||||
], ['user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role']]);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
json_success(null, 'Invoice extracted via WhatsApp');
|
||||
}
|
||||
Reference in New Issue
Block a user