Update: 2026-05-08 04:58:23

This commit is contained in:
Hamza-Ayed
2026-05-08 04:58:23 +03:00
parent 4721ca83da
commit 6db8986fca
48 changed files with 2212 additions and 108 deletions

View File

@@ -0,0 +1,93 @@
<?php
/**
* Musadaq Academy — Educational Content
* GET /v1/academy/articles
* GET /v1/academy/articles?category=tax
*
* Returns curated accounting and tax educational articles.
* Content is stored in-code for MVP, can be migrated to DB later.
*/
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$category = $_GET['category'] ?? null;
$search = $_GET['search'] ?? null;
// In-code content library (MVP — migrate to DB when content grows)
$articles = [
[
'id' => 'tax-101',
'category' => 'tax',
'title' => 'دليل ضريبة المبيعات الأردنية الشامل',
'summary' => 'كل ما تحتاج معرفته عن نسب ضريبة المبيعات في الأردن: العامة (16%)، المخفضة (4% و 8%)، والمعفاة.',
'content' => "## نسب ضريبة المبيعات في الأردن\n\n### النسبة العامة: 16%\nتُطبق على معظم السلع والخدمات.\n\n### النسبة المخفضة: 4%\n- الأدوية\n- المستلزمات الطبية\n\n### النسبة المخفضة: 8%\n- الخدمات السياحية\n- بعض المواد الغذائية المصنعة\n\n### معفاة من الضريبة (0%)\n- الخبز\n- الحليب\n- التعليم\n- الخدمات الصحية\n\n> ملاحظة: هذه المعلومات للإرشاد فقط. راجع دائرة ضريبة الدخل والمبيعات للتفاصيل الرسمية.",
'reading_time' => 3,
'icon' => '🏛️',
],
[
'id' => 'jofotara-guide',
'category' => 'jofotara',
'title' => 'كيف تربط شركتك بمنظومة جوفوترا',
'summary' => 'خطوات تسجيل شركتك والحصول على Client ID و Secret Key من منظومة الفوترة الإلكترونية.',
'content' => "## خطوات الربط بجوفوترا\n\n### 1. التسجيل في المنظومة\n- ادخل على portal.jofotara.gov.jo\n- سجّل بالرقم الضريبي لشركتك\n\n### 2. الحصول على المفاتيح\n- من لوحة التحكم، اختر \"إدارة التطبيقات\"\n- أنشئ تطبيق جديد\n- انسخ Client ID و Secret Key\n\n### 3. الربط في مُصادَق\n- افتح إعدادات الشركة\n- الصق Client ID و Secret Key\n- اضغط \"اختبار الاتصال\"\n\n> بعد الربط، يمكنك إرسال الفواتير لجوفوترا بضغطة واحدة!",
'reading_time' => 4,
'icon' => '🔗',
],
[
'id' => 'invoice-types',
'category' => 'invoicing',
'title' => 'أنواع الفواتير الإلكترونية في الأردن',
'summary' => 'الفرق بين فاتورة المبيعات، الإشعار الدائن، والإشعار المدين حسب UBL 2.1.',
'content' => "## أنواع الفواتير\n\n### 1. فاتورة مبيعات (Invoice)\nالنوع الأساسي — تُصدر عند بيع سلعة أو خدمة.\n\n### 2. إشعار دائن (Credit Note)\nيُصدر لتعديل فاتورة سابقة بالتخفيض (مرتجعات أو خصومات).\n\n### 3. إشعار مدين (Debit Note)\nيُصدر لتعديل فاتورة سابقة بالزيادة.\n\n### متطلبات UBL 2.1\n- كل فاتورة يجب أن تحتوي على رقم ضريبي صحيح\n- التاريخ بصيغة ISO\n- تفصيل البنود مع الكمية والسعر",
'reading_time' => 3,
'icon' => '📄',
],
[
'id' => 'ai-tips',
'category' => 'tips',
'title' => 'نصائح للحصول على أفضل نتائج من الذكاء الاصطناعي',
'summary' => 'كيف تصوّر الفاتورة لتحصل على استخراج دقيق بنسبة 99%.',
'content' => "## نصائح التصوير\n\n### ✅ افعل:\n- صوّر الفاتورة كاملة مع الحواف\n- تأكد من الإضاءة الجيدة\n- ضع الفاتورة على سطح مسطح\n- صوّر من الأعلى مباشرة (لا بزاوية)\n\n### ❌ لا تفعل:\n- لا تصوّر جزء من الفاتورة فقط\n- لا تصوّر فاتورة مطوية أو مجعدة\n- لا تصوّر في إضاءة خافتة\n- لا ترفع صور أقل من 300x300 بكسل\n\n### 💡 نصيحة إضافية:\nاستخدم ميزة الـ Batch Scan لتصوير عدة فواتير دفعة واحدة!",
'reading_time' => 2,
'icon' => '💡',
],
[
'id' => 'security-guide',
'category' => 'security',
'title' => 'كيف يحمي مُصادَق بياناتك',
'summary' => 'نظرة على تقنيات التشفير والحماية المستخدمة في المنصة.',
'content' => "## حماية بياناتك\n\n### تشفير AES-256-GCM\nكل البيانات الحساسة (أسماء، أرقام ضريبية، مفاتيح API) مشفرة بأقوى معيار تشفير.\n\n### فصل البيانات (Multi-Tenancy)\nكل مكتب محاسبي معزول تماماً — لا يمكن لأي مكتب رؤية بيانات مكتب آخر.\n\n### مصادقة ثنائية\nتسجيل الدخول يتطلب OTP عبر واتساب بالإضافة لكلمة المرور.\n\n### HMAC Signature\nكل طلب API يتم التحقق من سلامته عبر توقيع رقمي.",
'reading_time' => 3,
'icon' => '🔒',
],
];
// Filter by category
if ($category) {
$articles = array_values(array_filter($articles, fn($a) => $a['category'] === $category));
}
// Search
if ($search) {
$searchLower = mb_strtolower($search);
$articles = array_values(array_filter($articles, fn($a) =>
str_contains(mb_strtolower($a['title']), $searchLower) ||
str_contains(mb_strtolower($a['summary']), $searchLower)
));
}
$categories = [
['key' => 'tax', 'name' => 'ضرائب', 'icon' => '🏛️'],
['key' => 'jofotara', 'name' => 'جوفوترا', 'icon' => '🔗'],
['key' => 'invoicing', 'name' => 'فوترة', 'icon' => '📄'],
['key' => 'tips', 'name' => 'نصائح', 'icon' => '💡'],
['key' => 'security', 'name' => 'أمان', 'icon' => '🔒'],
];
json_success([
'articles' => $articles,
'categories' => $categories,
'total' => count($articles),
], 'أكاديمية مُصادَق');

View File

@@ -51,5 +51,5 @@ try {
json_success(null, 'تم تخصيص المستخدم للشركة بنجاح');
} catch (\Exception $e) {
json_error('حدث خطأ أثناء التخصيص: ' . $e->getMessage(), 500);
safe_error($e, 'assignments/create', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.');
}

View File

@@ -37,5 +37,5 @@ try {
json_success($assignments);
} catch (\Exception $e) {
json_error('SQL Error: ' . $e->getMessage(), 500);
safe_error($e, 'assignments/index');
}

View File

@@ -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', 'خطأ في جلب سجل النشاط.');
}

View File

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

View File

@@ -0,0 +1,88 @@
<?php
/**
* AI Accounting Chatbot — "اسأل مُصادَق"
* POST /v1/chatbot/ask
* Body: { "question": "كم ضريبة المبيعات على الخدمات الرقمية؟" }
*
* AI-powered chatbot that answers accounting & tax questions
* with context from the user's own data when relevant.
*/
use App\Core\Database;
use App\Core\AI;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$data = json_decode(file_get_contents('php://input'), true);
$question = trim($data['question'] ?? '');
if (empty($question) || mb_strlen($question) < 3) {
json_error('يرجى كتابة سؤالك (3 أحرف على الأقل)', 422);
}
if (mb_strlen($question) > 500) {
json_error('السؤال طويل جداً (الحد 500 حرف)', 422);
}
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
try {
// 1. Gather user context (last month stats)
$contextStmt = $db->prepare("
SELECT
COUNT(*) as total_invoices,
COALESCE(SUM(grand_total), 0) as total_revenue,
COALESCE(SUM(tax_amount), 0) as total_tax
FROM invoices
WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
");
$contextStmt->execute([$tenantId]);
$context = $contextStmt->fetch();
$companyStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$companyStmt->execute([$tenantId]);
$companyCount = (int)$companyStmt->fetchColumn();
// 2. Build AI prompt
$systemPrompt = <<<PROMPT
أنت "مُصادَق" — مساعد محاسبي ذكي متخصص في المحاسبة والضرائب الأردنية.
قواعد:
1. أجب بالعربية دائماً وبشكل مختصر ومفيد
2. إذا كان السؤال عن ضرائب أردنية، استخدم نسب ضريبة المبيعات الأردنية (16% عامة، 4% و8% مخفضة، 0% معفاة)
3. إذا كان السؤال غير محاسبي، قل "أنا متخصص بالمحاسبة والضرائب فقط"
4. لا تعطِ نصائح قانونية نهائية — انصح بمراجعة محاسب قانوني للحالات المعقدة
5. إذا كان السؤال يتعلق ببيانات المستخدم، استخدم السياق المتاح
سياق المستخدم (آخر 30 يوم):
- عدد الفواتير: {$context['total_invoices']}
- إجمالي الإيرادات: {$context['total_revenue']} دينار
- إجمالي الضريبة: {$context['total_tax']} دينار
- عدد الشركات: {$companyCount}
PROMPT;
$aiResponse = AI::chat($systemPrompt, $question, $tenantId);
if (!$aiResponse) {
json_error('عذراً، لم أتمكن من معالجة سؤالك. حاول مرة أخرى.', 500);
}
// 3. Log the conversation
$db->prepare("
INSERT INTO chatbot_history (id, user_id, tenant_id, question, answer, created_at)
VALUES (UUID(), ?, ?, ?, ?, NOW())
")->execute([$userId, $tenantId, $question, $aiResponse]);
json_success([
'answer' => $aiResponse,
'question' => $question,
'timestamp' => date('c'),
], 'إجابة مُصادَق');
} catch (\Exception $e) {
safe_error($e, 'chatbot/ask', 'حدث خطأ في المساعد الذكي.');
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* Chatbot History
* GET /v1/chatbot/history
* Returns user's recent chatbot conversations.
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$pagination = paginate_params(20, 50);
$countStmt = $db->prepare("SELECT COUNT(*) FROM chatbot_history WHERE user_id = ?");
$countStmt->execute([$decoded['user_id']]);
$total = (int)$countStmt->fetchColumn();
$stmt = $db->prepare("
SELECT id, question, answer, created_at
FROM chatbot_history
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
");
$stmt->execute([$decoded['user_id']]);
json_paginated($stmt->fetchAll(), $total, $pagination);

View File

@@ -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', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
}

View File

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

View File

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

View File

@@ -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', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.');
}

View File

@@ -86,7 +86,7 @@ try {
} catch (\Exception $e) {
if (isset($db)) $db->rollBack();
json_error('فشل معالجة ملف الاكسل: ' . $e->getMessage(), 500);
safe_error($e, 'excel/import', 'فشل معالجة ملف الإكسل.');
}
/**

View File

@@ -0,0 +1,15 @@
<?php
/**
* Gamification Profile
* GET /v1/gamification/profile
* Returns user's points, level, badges, and progress.
*/
use App\Services\GamificationService;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$profile = GamificationService::getProfile($decoded['user_id'], $decoded['tenant_id']);
json_success($profile, 'ملفك التنافسي');

View File

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

View File

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

View File

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

View File

@@ -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', 'فشل تحديث الفاتورة.');
}

View File

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

View File

@@ -0,0 +1,74 @@
<?php
/**
* Marketplace — Accountant Directory & Service Listings
* GET /v1/marketplace/listings
* GET /v1/marketplace/listings?city=amman&specialty=tax
*
* Public directory where accounting offices can list their services
* and businesses can find accountants.
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$pagination = paginate_params(20, 50);
$city = $_GET['city'] ?? null;
$specialty = $_GET['specialty'] ?? null;
$search = $_GET['search'] ?? null;
$where = "ml.is_active = 1";
$params = [];
if ($city) {
$where .= " AND ml.city = ?";
$params[] = $city;
}
if ($specialty) {
$where .= " AND ml.specialty = ?";
$params[] = $specialty;
}
if ($search) {
$where .= " AND (ml.office_name LIKE ? OR ml.description LIKE ?)";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
}
try {
// Count
$countStmt = $db->prepare("SELECT COUNT(*) FROM marketplace_listings ml WHERE {$where}");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
// Fetch
$stmt = $db->prepare("
SELECT ml.*, t.name as tenant_name
FROM marketplace_listings ml
LEFT JOIN tenants t ON ml.tenant_id = t.id
WHERE {$where}
ORDER BY ml.is_featured DESC, ml.rating DESC, ml.created_at DESC
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
");
$stmt->execute($params);
$listings = $stmt->fetchAll();
// Decrypt names
foreach ($listings as &$l) {
if (!empty($l['tenant_name'])) {
$dec = Encryption::decrypt($l['tenant_name']);
$l['tenant_name'] = ($dec !== false && $dec !== null) ? $dec : $l['tenant_name'];
}
}
$cities = ['amman' => 'عمّان', 'irbid' => 'إربد', 'zarqa' => 'الزرقاء', 'aqaba' => 'العقبة', 'salt' => 'السلط', 'madaba' => 'مأدبا', 'karak' => 'الكرك', 'other' => 'أخرى'];
$specialties = ['tax' => 'ضرائب', 'audit' => 'تدقيق', 'bookkeeping' => 'مسك دفاتر', 'payroll' => 'رواتب', 'consulting' => 'استشارات', 'general' => 'عام'];
json_paginated($listings, $total, $pagination, 'سوق المحاسبين');
} catch (\Exception $e) {
safe_error($e, 'marketplace/listings', 'حدث خطأ في تحميل القوائم.');
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* Marketplace — Create/Update My Listing
* POST /v1/marketplace/my-listing
* Body: { "office_name": "...", "city": "amman", "specialty": "tax", "description": "...", "phone": "...", "email": "..." }
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$data = input();
$errors = Validator::validate($data, [
'office_name' => 'required',
'city' => 'required',
'specialty' => 'required',
]);
if ($errors) {
json_error('بيانات ناقصة', 422, $errors);
}
try {
// Check if listing exists
$existing = $db->prepare("SELECT id FROM marketplace_listings WHERE tenant_id = ? LIMIT 1");
$existing->execute([$tenantId]);
$row = $existing->fetch();
if ($row) {
// Update
$db->prepare("
UPDATE marketplace_listings SET
office_name = ?, city = ?, specialty = ?, description = ?,
contact_phone = ?, contact_email = ?, updated_at = NOW()
WHERE tenant_id = ?
")->execute([
$data['office_name'], $data['city'], $data['specialty'],
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '',
$tenantId
]);
json_success(['id' => $row['id']], 'تم تحديث القائمة بنجاح');
} else {
// Create
$id = Database::generateUuid();
$db->prepare("
INSERT INTO marketplace_listings (id, tenant_id, office_name, city, specialty, description, contact_phone, contact_email, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW())
")->execute([
$id, $tenantId, $data['office_name'], $data['city'], $data['specialty'],
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? ''
]);
json_success(['id' => $id], 'تم إضافة مكتبك للسوق بنجاح! 🎉');
}
} catch (\Exception $e) {
safe_error($e, 'marketplace/my-listing', 'حدث خطأ في حفظ القائمة.');
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Apply Referral Code During Registration
* POST /v1/referral/apply
* Body: { "referral_code": "MSQ-ABC123" }
*
* Called during registration to link a new user to their referrer.
*/
use App\Core\Database;
use App\Core\Security;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$data = Security::sanitize(input());
$code = $data['referral_code'] ?? null;
if (!$code) {
json_error('رمز الإحالة مطلوب', 422);
}
$userId = $decoded['user_id'];
$tenantId = $decoded['tenant_id'] ?? null;
try {
// 1. Validate the referral code
$stmt = $db->prepare("SELECT * FROM referral_codes WHERE code = ? LIMIT 1");
$stmt->execute([$code]);
$referralCode = $stmt->fetch();
if (!$referralCode) {
json_error('رمز الإحالة غير صالح', 404);
}
// Prevent self-referral
if ($referralCode['user_id'] === $userId) {
json_error('لا يمكنك استخدام رمز الإحالة الخاص بك', 400);
}
// Check if user already used a referral
$checkStmt = $db->prepare("SELECT id FROM referrals WHERE referred_id = ? LIMIT 1");
$checkStmt->execute([$userId]);
if ($checkStmt->fetch()) {
json_error('لقد استخدمت رمز إحالة مسبقاً', 409);
}
// 2. Create the referral record
$db->beginTransaction();
$referralId = \App\Core\Database::generateUuid();
$stmt = $db->prepare("
INSERT INTO referrals (id, referrer_id, referred_id, referral_code_id, status, created_at)
VALUES (?, ?, ?, ?, 'registered', NOW())
");
$stmt->execute([$referralId, $referralCode['user_id'], $userId, $referralCode['id']]);
// 3. Notify the referrer
try {
$notifStmt = $db->prepare("
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
VALUES (UUID(), ?, ?, 'referral', '🎉 إحالة جديدة!', 'شخص جديد انضم باستخدام رمز إحالتك', ?, NOW())
");
$notifStmt->execute([
$referralCode['tenant_id'],
$referralCode['user_id'],
json_encode(['referral_id' => $referralId, 'code' => $code])
]);
} catch (\Exception $e) {
// Don't fail the whole operation if notification fails
error_log("[referral/apply] Notification failed: " . $e->getMessage());
}
$db->commit();
json_success([
'referral_id' => $referralId,
'referrer_code' => $code,
'status' => 'registered',
], 'تم تطبيق رمز الإحالة بنجاح! 🎉');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
safe_error($e, 'referral/apply', 'حدث خطأ في تطبيق رمز الإحالة.');
}

View File

@@ -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', 'حدث خطأ في نظام الإحالة.');
}

View File

@@ -0,0 +1,142 @@
<?php
/**
* AI Company Health Report
* GET /v1/reports/company-health?company_id=xxx
*
* Generates an AI-powered financial health analysis using invoice data.
* Returns insights, warnings, and recommendations in Arabic.
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\AI;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
$companyId = $_GET['company_id'] ?? null;
if (!$companyId) {
json_error('معرّف الشركة مطلوب', 422);
}
// Verify access
$accessQuery = ($role === 'super_admin')
? "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND deleted_at IS NULL"
: "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL";
$accessParams = ($role === 'super_admin') ? [$companyId] : [$companyId, $tenantId];
$stmt = $db->prepare($accessQuery);
$stmt->execute($accessParams);
$company = $stmt->fetch();
if (!$company) {
json_error('الشركة غير موجودة أو ليس لديك صلاحية', 404);
}
$companyName = Encryption::decrypt($company['name']) ?: $company['name'];
try {
// 1. Gather last 3 months of data
$months = [];
for ($i = 0; $i < 3; $i++) {
$m = date('m', strtotime("-{$i} months"));
$y = date('Y', strtotime("-{$i} months"));
$stmt = $db->prepare("
SELECT
COUNT(*) as total_invoices,
COALESCE(SUM(grand_total), 0) as revenue,
COALESCE(SUM(tax_amount), 0) as tax,
COALESCE(SUM(discount_total), 0) as discounts,
COALESCE(AVG(grand_total), 0) as avg_invoice,
SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count,
SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count
FROM invoices
WHERE company_id = ? AND MONTH(created_at) = ? AND YEAR(created_at) = ?
");
$stmt->execute([$companyId, $m, $y]);
$data = $stmt->fetch();
$data['month'] = (int)$m;
$data['year'] = (int)$y;
$months[] = $data;
}
// 2. Pending invoices count
$pendingStmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE company_id = ? AND status = 'extracted'");
$pendingStmt->execute([$companyId]);
$pendingCount = (int)$pendingStmt->fetchColumn();
// 3. Build AI prompt
$dataJson = json_encode([
'company_name' => $companyName,
'tin' => $company['tax_identification_number'],
'monthly_data' => $months,
'pending_invoices' => $pendingCount,
], JSON_UNESCAPED_UNICODE);
$prompt = <<<PROMPT
أنت محلل مالي خبير. حلل البيانات التالية لشركة وأعطِ تقريراً مختصراً بالعربية.
البيانات:
{$dataJson}
أعد الرد بصيغة JSON فقط بدون أي نص إضافي:
{
"health_score": (رقم من 1 إلى 10),
"health_label": ("ممتاز" أو "جيد" أو "متوسط" أو "يحتاج انتباه"),
"summary": "ملخص من سطرين عن الحالة المالية",
"insights": ["ملاحظة 1", "ملاحظة 2", "ملاحظة 3"],
"warnings": ["تحذير إن وجد"],
"recommendations": ["توصية 1", "توصية 2"]
}
PROMPT;
$aiResponse = AI::ask($prompt, $tenantId);
// Parse AI response
$report = null;
if ($aiResponse) {
// Extract JSON from response
$cleaned = preg_replace('/```json?\s*|```/', '', $aiResponse);
$report = json_decode(trim($cleaned), true);
}
// Fallback if AI fails
if (!$report) {
$currentMonth = $months[0] ?? [];
$prevMonth = $months[1] ?? [];
$score = 5;
if (($currentMonth['total_invoices'] ?? 0) > 0) $score += 2;
if (($currentMonth['submitted_count'] ?? 0) > 0) $score += 1;
if ($pendingCount === 0) $score += 1;
if (($currentMonth['revenue'] ?? 0) > ($prevMonth['revenue'] ?? 0)) $score += 1;
$report = [
'health_score' => min(10, $score),
'health_label' => $score >= 8 ? 'ممتاز' : ($score >= 6 ? 'جيد' : 'متوسط'),
'summary' => 'تقرير مبني على البيانات المتوفرة بدون تحليل AI.',
'insights' => ['عدد الفواتير: ' . ($currentMonth['total_invoices'] ?? 0)],
'warnings' => $pendingCount > 0 ? ["يوجد {$pendingCount} فاتورة بانتظار المراجعة"] : [],
'recommendations' => ['تأكد من إرسال جميع الفواتير المعتمدة لجوفوترا'],
];
}
json_success([
'company_id' => $companyId,
'company_name' => $companyName,
'report' => $report,
'data' => [
'monthly_summary' => $months,
'pending_count' => $pendingCount,
],
'generated_at' => date('c'),
], 'تقرير صحة الشركة');
} catch (\Exception $e) {
safe_error($e, 'reports/company-health', 'حدث خطأ في إنشاء التقرير.');
}

View File

@@ -0,0 +1,188 @@
<?php
/**
* SMS Bank Integration — Receive & Auto-Match Payments
* POST /v1/sms/receive
*
* Flow:
* 1. Android SMS Bot intercepts bank/wallet SMS
* 2. Sends it here: { "sender": "BANK_NAME", "message": "تم تحويل 45 دينار..." }
* 3. We save it in raw_sms_log with status "pending"
* 4. We immediately try to match it against pending payment requests
* 5. If matched → confirm payment → update subscription → notify user
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\AuditLogger;
// Auth: Verify webhook secret (shared between Android bot and server)
$webhookSecret = env('SMS_WEBHOOK_SECRET', '');
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? $_SERVER['HTTP_X_SMS_SECRET'] ?? '';
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$json_data = file_get_contents('php://input');
$data = json_decode($json_data, true);
if (!$data || empty($data['sender']) || empty($data['message'])) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'بيانات غير مكتملة. يجب إرسال sender و message.']);
exit;
}
$sender = trim($data['sender']);
$message = trim($data['message']);
$db = Database::getInstance();
try {
// 1. Save raw SMS log
$smsId = \App\Core\Database::generateUuid();
$stmt = $db->prepare("
INSERT INTO raw_sms_log (id, sender, message_body, status, received_at)
VALUES (?, ?, ?, 'pending', NOW())
");
$stmt->execute([$smsId, $sender, $message]);
// 2. Try to auto-match with pending payments
$matchResult = matchPayment($db, $smsId, $sender, $message);
http_response_code(200);
echo json_encode([
'status' => 'success',
'message' => 'SMS received and processed.',
'matched' => $matchResult['matched'],
'details' => $matchResult['details'] ?? null,
], JSON_UNESCAPED_UNICODE);
} catch (\Exception $e) {
error_log("[sms/receive] Error: " . $e->getMessage());
http_response_code(200); // Return 200 so bot doesn't retry
echo json_encode(['status' => 'error', 'message' => 'خطأ داخلي في المعالجة.']);
}
/**
* Try to match the incoming SMS with a pending payment request.
*
* Matching logic:
* 1. Extract reference number from SMS (formats: MSQ-XXXX, REF-XXXX, or plain digits)
* 2. Extract amount from SMS
* 3. Find pending payment request matching reference OR amount
* 4. If matched → confirm payment → activate/extend subscription
*/
function matchPayment(\PDO $db, string $smsId, string $sender, string $message): array
{
// Extract reference number (MSQ-XXXX pattern or any 6+ digit number)
$reference = null;
if (preg_match('/MSQ-([A-Z0-9]{4,10})/i', $message, $m)) {
$reference = 'MSQ-' . strtoupper($m[1]);
} elseif (preg_match('/REF[:\s-]*([A-Z0-9]{4,12})/i', $message, $m)) {
$reference = $m[1];
}
// Extract amount (Arabic or English digits)
$amount = null;
$msgNormalized = strtr($message, ['٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']);
if (preg_match('/(\d+[\.,]?\d{0,3})\s*(دينار|JOD|JD)/iu', $msgNormalized, $m)) {
$amount = (float)str_replace(',', '.', $m[1]);
} elseif (preg_match('/(\d+[\.,]\d{2})/', $msgNormalized, $m)) {
$amount = (float)str_replace(',', '.', $m[1]);
}
if (!$reference && !$amount) {
// Can't match — mark SMS as unmatched
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', processed_at = NOW() WHERE id = ?")->execute([$smsId]);
return ['matched' => false, 'details' => 'لم يتم العثور على مرجع أو مبلغ في الرسالة'];
}
// Search for pending payment request
$where = "pr.status = 'pending'";
$params = [];
if ($reference) {
$where .= " AND pr.reference_number = ?";
$params[] = $reference;
}
if ($amount) {
$where .= " AND pr.amount = ?";
$params[] = $amount;
}
$stmt = $db->prepare("
SELECT pr.*, t.name as tenant_name
FROM payment_requests pr
LEFT JOIN tenants t ON pr.tenant_id = t.id
WHERE {$where}
ORDER BY pr.created_at DESC
LIMIT 1
");
$stmt->execute($params);
$payment = $stmt->fetch();
if (!$payment) {
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?")
->execute([$reference, $amount, $smsId]);
return ['matched' => false, 'details' => "مرجع: {$reference}, مبلغ: {$amount} — لم يتطابق مع أي طلب دفع"];
}
// MATCH FOUND — Process payment
$db->beginTransaction();
try {
// 1. Update payment request → confirmed
$db->prepare("
UPDATE payment_requests SET status = 'confirmed', sms_log_id = ?, confirmed_at = NOW() WHERE id = ?
")->execute([$smsId, $payment['id']]);
// 2. Update SMS log → matched
$db->prepare("
UPDATE raw_sms_log SET status = 'matched', payment_request_id = ?, extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?
")->execute([$payment['id'], $reference, $amount, $smsId]);
// 3. Activate/extend subscription
$planMonths = (int)($payment['plan_months'] ?? 1);
$db->prepare("
UPDATE subscriptions
SET is_active = 1,
started_at = COALESCE(started_at, NOW()),
expires_at = DATE_ADD(COALESCE(expires_at, NOW()), INTERVAL ? MONTH),
updated_at = NOW()
WHERE tenant_id = ?
")->execute([$planMonths, $payment['tenant_id']]);
// 4. Notify user
\App\Services\SmartNotifications::send(
$payment['tenant_id'],
$payment['user_id'] ?? '',
'payment_confirmed',
'✅ تم تأكيد الدفع!',
"تم تأكيد دفعة بقيمة {$payment['amount']} دينار. اشتراكك فعّال الآن.",
['payment_id' => $payment['id'], 'amount' => $payment['amount']]
);
// 5. Audit log
AuditLogger::log('payment.auto_confirmed', 'payment', $payment['id'], null, [
'sms_id' => $smsId,
'sender' => $sender,
'reference' => $reference,
'amount' => $amount,
], ['user_id' => 'system', 'tenant_id' => $payment['tenant_id'], 'role' => 'system']);
$db->commit();
return [
'matched' => true,
'details' => "تم مطابقة الدفعة: {$payment['amount']} دينار — الاشتراك مُفعّل",
];
} catch (\Exception $e) {
$db->rollBack();
error_log("[sms/match] Failed: " . $e->getMessage());
return ['matched' => false, 'details' => 'خطأ أثناء تأكيد الدفعة'];
}
}

View File

@@ -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', 'حدث خطأ أثناء تعيين الباقة.');
}

View File

@@ -78,6 +78,6 @@ try {
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
} catch (\Exception $e) {
$db->rollBack();
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.');
}

View File

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

View File

@@ -56,5 +56,5 @@ try {
]);
} catch (\Exception $e) {
json_error('Stats Error: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/stats');
}

View File

@@ -59,5 +59,5 @@ try {
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
} catch (\Exception $e) {
json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500);
safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.');
}

View File

@@ -31,7 +31,7 @@ $errors = Validator::validate($data, [
'name' => 'required',
'email' => 'required|email',
'phone' => 'required',
'password' => 'required',
'password' => 'required|strong_password',
'role' => 'required'
]);

View File

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

View 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', 'حدث خطأ في إنشاء كود الربط.');
}

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