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

@@ -13,12 +13,36 @@ final class Validator
{
$errors = [];
foreach ($rules as $field => $rule) {
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
$value = $data[$field] ?? null;
if (str_contains($rule, 'required') && (empty($value) && $value !== '0')) {
$errors[$field] = "The {$field} field is required.";
continue; // Skip further rules if required field is missing
}
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
if (str_contains($rule, 'email') && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "The {$field} must be a valid email address.";
}
// Password strength: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit
if (str_contains($rule, 'strong_password') && !empty($value)) {
if (strlen($value) < 8) {
$errors[$field] = 'كلمة المرور يجب أن تكون 8 أحرف على الأقل.';
} elseif (!preg_match('/[A-Z]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف كبير واحد على الأقل.';
} elseif (!preg_match('/[a-z]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف صغير واحد على الأقل.';
} elseif (!preg_match('/[0-9]/', $value)) {
$errors[$field] = 'كلمة المرور يجب أن تحتوي على رقم واحد على الأقل.';
}
}
// Generic min length: min:8
if (preg_match('/min:(\d+)/', $rule, $m) && !empty($value)) {
if (mb_strlen($value) < (int)$m[1]) {
$errors[$field] = "The {$field} must be at least {$m[1]} characters.";
}
}
}
return $errors;
}

View File

@@ -0,0 +1,155 @@
<?php
/**
* Gamification Service — Badges & Points
*
* Awards points and badges based on user actions.
* Call GamificationService::award() from relevant endpoints.
*/
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
class GamificationService
{
// Points per action
private const POINTS = [
'invoice_uploaded' => 5,
'invoice_approved' => 10,
'jofotara_submitted' => 15,
'company_created' => 20,
'referral_registered' => 50,
'first_login' => 10,
'streak_7_days' => 30,
'streak_30_days' => 100,
];
// Badge definitions
private const BADGES = [
'starter' => ['name' => 'بداية موفقة', 'icon' => '🌟', 'desc' => 'رفعت أول فاتورة', 'condition' => 'invoices >= 1'],
'active_10' => ['name' => 'نشيط', 'icon' => '🔥', 'desc' => '10 فواتير مرفوعة', 'condition' => 'invoices >= 10'],
'pro_50' => ['name' => 'محترف', 'icon' => '💎', 'desc' => '50 فاتورة مرفوعة', 'condition' => 'invoices >= 50'],
'master_200' => ['name' => 'خبير فوترة', 'icon' => '👑', 'desc' => '200 فاتورة مرفوعة', 'condition' => 'invoices >= 200'],
'jofotara_first' => ['name' => 'رسمي', 'icon' => '🏛️', 'desc' => 'أول إرسال لجوفوترا', 'condition' => 'submitted >= 1'],
'jofotara_50' => ['name' => 'فوترة ذهبية', 'icon' => '🏆', 'desc' => '50 فاتورة مرسلة لجوفوترا', 'condition' => 'submitted >= 50'],
'multi_company' => ['name' => 'مدير شركات', 'icon' => '🏢', 'desc' => 'تدير 3 شركات أو أكثر', 'condition' => 'companies >= 3'],
'referrer' => ['name' => 'سفير مُصادَق', 'icon' => '🤝', 'desc' => 'أحلت مستخدم جديد', 'condition' => 'referrals >= 1'],
'streak_week' => ['name' => 'مثابر', 'icon' => '📅', 'desc' => 'دخلت 7 أيام متتالية', 'condition' => 'streak >= 7'],
];
/**
* Award points for an action
*/
public static function award(string $userId, string $tenantId, string $action): void
{
try {
$points = self::POINTS[$action] ?? 0;
if ($points === 0) return;
$db = Database::getInstance();
// Add points
$db->prepare("
INSERT INTO user_points (id, user_id, tenant_id, action, points, created_at)
VALUES (UUID(), ?, ?, ?, ?, NOW())
")->execute([$userId, $tenantId, $action, $points]);
// Check for new badges
self::checkBadges($userId, $tenantId);
} catch (\Throwable $e) {
error_log("[Gamification] Award failed: " . $e->getMessage());
}
}
/**
* Check and award any earned badges
*/
private static function checkBadges(string $userId, string $tenantId): void
{
$db = Database::getInstance();
// Get user stats
$invoices = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ?")->execute([$tenantId])?->fetchColumn() ?: 0;
$submitted = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'submitted'")->execute([$tenantId])?->fetchColumn() ?: 0;
$companies = (int)$db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL")->execute([$tenantId])?->fetchColumn() ?: 0;
$referrals = (int)$db->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id = ?")->execute([$userId])?->fetchColumn() ?: 0;
// Get existing badges
$existingStmt = $db->prepare("SELECT badge_key FROM user_badges WHERE user_id = ?");
$existingStmt->execute([$userId]);
$existing = $existingStmt->fetchAll(\PDO::FETCH_COLUMN);
$stats = compact('invoices', 'submitted', 'companies', 'referrals');
foreach (self::BADGES as $key => $badge) {
if (in_array($key, $existing)) continue;
if (self::evaluateCondition($badge['condition'], $stats)) {
$db->prepare("
INSERT INTO user_badges (id, user_id, tenant_id, badge_key, badge_name, badge_icon, earned_at)
VALUES (UUID(), ?, ?, ?, ?, ?, NOW())
")->execute([$userId, $tenantId, $key, $badge['name'], $badge['icon']]);
// Notify user
SmartNotifications::send($tenantId, $userId, 'badge_earned',
"{$badge['icon']} شارة جديدة: {$badge['name']}!",
$badge['desc'],
['badge_key' => $key]
);
}
}
}
/**
* Simple condition evaluator
*/
private static function evaluateCondition(string $condition, array $stats): bool
{
if (preg_match('/(\w+)\s*>=\s*(\d+)/', $condition, $m)) {
$field = $m[1];
$value = (int)$m[2];
return ($stats[$field] ?? 0) >= $value;
}
return false;
}
/**
* Get user's gamification profile
*/
public static function getProfile(string $userId, string $tenantId): array
{
$db = Database::getInstance();
// Total points
$pointsStmt = $db->prepare("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?");
$pointsStmt->execute([$userId]);
$totalPoints = (int)$pointsStmt->fetchColumn();
// Badges
$badgesStmt = $db->prepare("SELECT badge_key, badge_name, badge_icon, earned_at FROM user_badges WHERE user_id = ? ORDER BY earned_at DESC");
$badgesStmt->execute([$userId]);
$badges = $badgesStmt->fetchAll();
// Level (every 100 points = 1 level)
$level = max(1, (int)floor($totalPoints / 100) + 1);
$levelNames = ['', 'مبتدئ', 'ناشط', 'متقدم', 'خبير', 'أسطورة', 'سيد الفوترة'];
$levelName = $levelNames[min($level, count($levelNames) - 1)] ?? 'أسطورة';
// Progress to next level
$pointsInLevel = $totalPoints % 100;
$progressPercent = $pointsInLevel;
return [
'total_points' => $totalPoints,
'level' => $level,
'level_name' => $levelName,
'progress_percent' => $progressPercent,
'badges' => $badges,
'badges_count' => count($badges),
'available_badges' => count(self::BADGES),
];
}
}

View File

@@ -158,17 +158,87 @@ class NotificationService
}
/**
* Get OAuth2 Access Token for Firebase (Cache this in production!)
* Note: This requires a JWT library or manual signing.
* For simplicity, we assume the user might use a Google Auth library.
* But since we avoid extra deps, I will provide a minimal implementation or suggestion.
* Get OAuth2 Access Token for Firebase using Service Account JWT
* Self-contained: no external libraries needed.
*/
private function getAccessToken(): ?string
{
// This is a complex part that usually requires 'google/auth' library.
// For now, I will return null and tell the user they need to install google/auth via composer
// OR I can write a minimal JWT signer for Google Auth if they don't want composer.
error_log("[NotificationService] OAuth2 Token generation needs google/auth library.");
// Check cache first (token is valid for 1 hour, we cache for 50 min)
$cacheFile = STORAGE_PATH . '/cache/fcm_token.json';
if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached && ($cached['expires_at'] ?? 0) > time()) {
return $cached['access_token'];
}
}
if (!file_exists($this->serviceAccountPath)) {
error_log("[NotificationService] Firebase service account file missing");
return null;
}
$sa = json_decode(file_get_contents($this->serviceAccountPath), true);
if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) {
error_log("[NotificationService] Invalid service account JSON");
return null;
}
// Build JWT
$now = time();
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
$payload = json_encode([
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
]);
$b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '=');
$b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
$signingInput = $b64Header . '.' . $b64Payload;
$privateKey = openssl_pkey_get_private($sa['private_key']);
if (!$privateKey) {
error_log("[NotificationService] Failed to parse private key");
return null;
}
openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
$jwt = $signingInput . '.' . $b64Signature;
// Exchange JWT for access token
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[NotificationService] Token exchange failed ($httpCode): $response");
return null;
}
$tokenData = json_decode($response, true);
$accessToken = $tokenData['access_token'] ?? null;
if ($accessToken) {
// Cache for 50 minutes
@file_put_contents($cacheFile, json_encode([
'access_token' => $accessToken,
'expires_at' => $now + 3000,
]));
}
return $accessToken;
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* Smart Notification Triggers
*
* Centralized service for sending intelligent, context-aware notifications.
* Call these methods from relevant endpoints (e.g., after invoice upload, approval, etc.)
*/
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
class SmartNotifications
{
/**
* Notify admin when quota usage reaches 80%
*/
public static function checkQuotaWarning(string $tenantId): void
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) return;
$usage = ($sub['max_invoices_per_month'] > 0)
? ($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100
: 0;
if ($usage >= 80 && $usage < 100) {
// Find admin user
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
$adminStmt->execute([$tenantId]);
$adminId = $adminStmt->fetchColumn();
if ($adminId) {
self::send($tenantId, $adminId, 'quota_warning',
'⚠️ اقتربت من حد الباقة',
'استخدمت ' . round($usage) . '% من حصة الفواتير الشهرية. فكّر بالترقية لتجنب التوقف.',
['usage_percent' => round($usage)]
);
}
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Quota warning failed: " . $e->getMessage());
}
}
/**
* Notify user when invoice is approved
*/
public static function invoiceApproved(string $tenantId, string $uploaderId, string $invoiceId, string $invoiceNumber): void
{
self::send($tenantId, $uploaderId, 'invoice_approved',
'✅ تم اعتماد الفاتورة',
"الفاتورة رقم {$invoiceNumber} تم اعتمادها وهي جاهزة للإرسال لجوفوترا.",
['invoice_id' => $invoiceId]
);
}
/**
* Notify when JoFotara submission succeeds
*/
public static function jofotaraSuccess(string $tenantId, string $userId, string $invoiceId, string $uuid): void
{
self::send($tenantId, $userId, 'jofotara_success',
'🎉 تم إرسال الفاتورة لجوفوترا',
"الفاتورة أُرسلت بنجاح. UUID: {$uuid}",
['invoice_id' => $invoiceId, 'jofotara_uuid' => $uuid]
);
}
/**
* Notify when JoFotara submission fails
*/
public static function jofotaraRejected(string $tenantId, string $userId, string $invoiceId, string $error): void
{
self::send($tenantId, $userId, 'jofotara_rejected',
'❌ رُفضت الفاتورة من جوفوترا',
"الفاتورة لم تُقبل: {$error}",
['invoice_id' => $invoiceId]
);
}
/**
* Notify admin about pending invoices (daily digest)
*/
public static function pendingInvoicesDigest(string $tenantId): void
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'extracted'");
$stmt->execute([$tenantId]);
$count = (int)$stmt->fetchColumn();
if ($count === 0) return;
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
$adminStmt->execute([$tenantId]);
$adminId = $adminStmt->fetchColumn();
if ($adminId) {
self::send($tenantId, $adminId, 'pending_digest',
"📋 لديك {$count} فاتورة بانتظار المراجعة",
"هناك {$count} فاتورة مستخرجة لم تُراجع بعد. راجعها واعتمدها لإرسالها لجوفوترا.",
['pending_count' => $count]
);
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Pending digest failed: " . $e->getMessage());
}
}
/**
* Welcome notification for new users
*/
public static function welcomeUser(string $tenantId, string $userId, string $name): void
{
self::send($tenantId, $userId, 'welcome',
"مرحباً بك في مُصادَق، {$name}! 🎉",
'ابدأ برفع أول فاتورة — صوّرها أو ارفع PDF والذكاء الاصطناعي يكمل الباقي.',
[]
);
}
/**
* Core send method — writes to DB (push handled by NotificationService)
*/
private static function send(string $tenantId, string $userId, string $type, string $title, string $body, array $data): void
{
try {
$db = Database::getInstance();
// Deduplicate: don't send same type within 1 hour
$dedup = $db->prepare("
SELECT id FROM notifications
WHERE user_id = ? AND type = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
LIMIT 1
");
$dedup->execute([$userId, $type]);
if ($dedup->fetch()) return;
$db->prepare("
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, NOW())
")->execute([$tenantId, $userId, $type, $title, $body, json_encode($data, JSON_UNESCAPED_UNICODE)]);
// Try push notification (non-blocking)
try {
$notifService = new NotificationService();
$notifService->sendNotification($userId, $title, $body, $data);
} catch (\Throwable $e) {
// Push failure is non-critical
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Send failed: " . $e->getMessage());
}
}
}

View File

@@ -13,6 +13,7 @@ define('STORAGE_PATH', ROOT_PATH . '/storage');
// 2. Load Environment & Helpers FIRST
require_once APP_PATH . '/bootstrap/env.php';
require_once APP_PATH . '/helpers/helpers.php';
require_once APP_PATH . '/helpers/pagination.php';
// Load Composer Autoloader
$vendorAutoload = ROOT_PATH . '/vendor/autoload.php';
@@ -25,8 +26,7 @@ $dirs = ['/cache', '/logs', '/invoices', '/exports'];
foreach ($dirs as $d) {
$path = STORAGE_PATH . $d;
if (!is_dir($path)) {
mkdir($path, 0777, true);
chmod($path, 0777);
mkdir($path, 0755, true);
}
}
@@ -66,7 +66,27 @@ header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: SAMEORIGIN");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
// CSP: Allow self + known CDNs (Tailwind, Alpine, Google Fonts)
$csp = "default-src 'self'; "
. "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; "
. "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
. "font-src 'self' https://fonts.gstatic.com; "
. "img-src 'self' data:; "
. "connect-src 'self';";
header("Content-Security-Policy: $csp");
// 6. Request body size limit (2MB for JSON, file uploads handled separately)
if (isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 2 * 1024 * 1024) {
if (empty($_FILES)) { // Don't block file uploads
http_response_code(413);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Request body too large'], JSON_UNESCAPED_UNICODE);
exit;
}
}
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
spl_autoload_register(function ($class) {

View File

@@ -38,3 +38,19 @@ if (!function_exists('dd')) {
die();
}
}
if (!function_exists('safe_error')) {
/**
* Log exception details securely and return a safe user-facing message.
* Full details go to error_log; users only see a generic Arabic message.
*
* @param \Throwable $e The caught exception
* @param string $context Short label for the endpoint (e.g. 'invoices/upload')
* @param string $userMsg Arabic message shown to the user
* @param int $code HTTP status code
*/
function safe_error(\Throwable $e, string $context, string $userMsg = 'حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.', int $code = 500): void {
error_log("[{$context}] " . get_class($e) . ': ' . $e->getMessage() . ' | ' . $e->getFile() . ':' . $e->getLine());
json_error($userMsg, $code);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* Pagination Helper
*
* Usage:
* $pagination = paginate_params(); // extracts page, per_page from query string
* // Use $pagination['limit'] and $pagination['offset'] in SQL
* // Wrap results: json_paginated($items, $totalCount, $pagination);
*/
if (!function_exists('paginate_params')) {
/**
* Extract pagination parameters from the query string.
*
* @param int $defaultPerPage Default items per page
* @param int $maxPerPage Maximum allowed per page (prevents abuse)
* @return array ['page' => int, 'per_page' => int, 'limit' => int, 'offset' => int]
*/
function paginate_params(int $defaultPerPage = 25, int $maxPerPage = 100): array
{
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = min($maxPerPage, max(1, (int)($_GET['per_page'] ?? $defaultPerPage)));
$offset = ($page - 1) * $perPage;
return [
'page' => $page,
'per_page' => $perPage,
'limit' => $perPage,
'offset' => $offset,
];
}
}
if (!function_exists('json_paginated')) {
/**
* Return a paginated JSON response with metadata.
*
* @param array $items The current page of results
* @param int $total Total count of all matching records
* @param array $pagination Output from paginate_params()
* @param string $message Optional success message
*/
function json_paginated(array $items, int $total, array $pagination, string $message = 'Success'): void
{
$totalPages = (int)ceil($total / max(1, $pagination['per_page']));
json_success([
'items' => $items,
'pagination' => [
'page' => $pagination['page'],
'per_page' => $pagination['per_page'],
'total' => $total,
'total_pages' => $totalPages,
'has_next' => $pagination['page'] < $totalPages,
'has_prev' => $pagination['page'] > 1,
],
], $message);
}
}

View File

@@ -0,0 +1,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)
$where = '1=1';
} else {
$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 u.tenant_id = ?
WHERE $where
ORDER BY u.created_at DESC
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
");
$stmt->execute([$tenantId]);
} else {
// Other roles shouldn't see user list
json_error('Unauthorized', 403);
}
$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');
}

View File

@@ -0,0 +1,31 @@
-- Gamification Tables for Musadaq
-- Run this migration on your production database
-- Points tracking
CREATE TABLE IF NOT EXISTS user_points (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
tenant_id VARCHAR(36) NOT NULL,
action VARCHAR(50) NOT NULL,
points INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_points_user (user_id),
INDEX idx_user_points_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Badge tracking
CREATE TABLE IF NOT EXISTS user_badges (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
tenant_id VARCHAR(36) NOT NULL,
badge_key VARCHAR(50) NOT NULL,
badge_name VARCHAR(100) NOT NULL,
badge_icon VARCHAR(10) NOT NULL DEFAULT '🏅',
earned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_badge (user_id, badge_key),
INDEX idx_user_badges_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- WhatsApp link code column (add to users table)
ALTER TABLE users ADD COLUMN IF NOT EXISTS whatsapp_link_code VARCHAR(10) DEFAULT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS whatsapp_linked TINYINT(1) DEFAULT 0;

View File

@@ -0,0 +1,14 @@
-- Chatbot History Table
-- Run this migration on your production database
CREATE TABLE IF NOT EXISTS chatbot_history (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
tenant_id VARCHAR(36) NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_chatbot_user (user_id),
INDEX idx_chatbot_tenant (tenant_id),
INDEX idx_chatbot_date (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,21 @@
-- SMS Bank Integration Tables
-- Run this migration on your production database
CREATE TABLE IF NOT EXISTS raw_sms_log (
id VARCHAR(36) PRIMARY KEY,
sender VARCHAR(100) NOT NULL,
message_body TEXT NOT NULL,
status ENUM('pending', 'matched', 'unmatched', 'error') DEFAULT 'pending',
payment_request_id VARCHAR(36) DEFAULT NULL,
extracted_ref VARCHAR(50) DEFAULT NULL,
extracted_amount DECIMAL(12,3) DEFAULT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME DEFAULT NULL,
INDEX idx_sms_status (status),
INDEX idx_sms_payment (payment_request_id),
INDEX idx_sms_date (received_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Add sms_log_id to payment_requests if not exists
ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS sms_log_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS confirmed_at DATETIME DEFAULT NULL;

View File

@@ -0,0 +1,20 @@
-- Marketplace Tables
CREATE TABLE IF NOT EXISTS marketplace_listings (
id VARCHAR(36) PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL,
office_name VARCHAR(200) NOT NULL,
city VARCHAR(50) NOT NULL DEFAULT 'amman',
specialty VARCHAR(50) NOT NULL DEFAULT 'general',
description TEXT DEFAULT NULL,
contact_phone VARCHAR(50) DEFAULT NULL,
contact_email VARCHAR(100) DEFAULT NULL,
rating DECIMAL(2,1) DEFAULT 0,
is_featured TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT NULL,
UNIQUE KEY uk_tenant (tenant_id),
INDEX idx_city (city),
INDEX idx_specialty (specialty),
INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -134,6 +134,8 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- camerawesome (from `.symlinks/plugins/camerawesome/ios`)
@@ -156,6 +158,7 @@ DEPENDENCIES:
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
trunk:
@@ -217,6 +220,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/speech_to_text/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
camerawesome: a961fa32dafc00d2f093d824311c84f849586b58
@@ -255,6 +260,7 @@ SPEC CHECKSUMS:
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079

View File

@@ -82,6 +82,39 @@ $routes = [
'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'],
'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'],
'v1/voice/parse-intent-grok' => ['POST', 'voice/grok_intent.php'],
// Referral System
'v1/referral/apply' => ['POST', 'referral/apply.php'],
// AI Reports
'v1/reports/company-health' => ['GET', 'reports/company_health.php'],
// Payment Upload
'v1/payments/upload-receipt' => ['POST', 'payments/upload_receipt.php'],
// WhatsApp Bot
'v1/whatsapp/webhook' => ['POST', 'whatsapp/webhook.php'],
'v1/whatsapp/link-code' => ['GET', 'whatsapp/link_code.php'],
// Gamification
'v1/gamification/profile' => ['GET', 'gamification/profile.php'],
// AI Chatbot
'v1/chatbot/ask' => ['POST', 'chatbot/ask.php'],
'v1/chatbot/history' => ['GET', 'chatbot/history.php'],
// Academy
'v1/academy/articles' => ['GET', 'academy/articles.php'],
// Excel Import (was missing!)
'v1/excel/import' => ['POST', 'excel/import.php'],
// SMS Bank Integration
'v1/sms/receive' => ['POST', 'sms/receive.php'],
// Marketplace
'v1/marketplace/listings' => ['GET', 'marketplace/listings.php'],
'v1/marketplace/my-listing' => ['POST', 'marketplace/my_listing.php'],
];
if (isset($routes[$route])) {
@@ -97,11 +130,12 @@ if (isset($routes[$route])) {
if (file_exists($file)) {
require_once $file;
} else {
json_error("Endpoint file missing: {$route}", 500);
error_log("Router: Missing module file for route '{$route}': {$moduleFile}");
json_error('خدمة غير متوفرة حالياً', 500);
}
} else {
if (str_starts_with($route, 'v1/')) {
json_error("Not Found: {$route}", 404);
json_error('المسار المطلوب غير موجود', 404);
} else {
include __DIR__ . '/shell.php';
exit;

321
public/landing.php Normal file
View File

@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مُصادَق أتمتة الفواتير الضريبية بالذكاء الاصطناعي</title>
<meta name="description" content="مُصادَق: منصة أتمتة الفواتير الإلكترونية الأردنية بالذكاء الاصطناعي. صوّر الفاتورة والباقي علينا. متوافق مع جوفوترا.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--emerald: #10b981; --emerald-dark: #059669; --emerald-glow: rgba(16,185,129,0.15);
--bg: #060a12; --bg2: #0c1220; --bg3: #111827;
--text: #f0f6fc; --text2: #8b949e; --border: rgba(255,255,255,0.08);
--gold: #f59e0b; --navy: #16325c;
}
html { scroll-behavior: smooth; }
body { font-family: 'IBM Plex Sans Arabic', sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; overflow-x: hidden; }
a { color: var(--emerald); text-decoration: none; }
/* === NAV === */
.nav { position: fixed; top: 0; width: 100%; z-index: 100; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(20px); background: rgba(6,10,18,0.8); border-bottom: 1px solid var(--border); transition: all 0.3s; }
.nav-logo { font-size: 1.5rem; font-weight: 700; color: var(--emerald); }
.nav-links { display: flex; gap: 2rem; align-items: center; }
.nav-links a { color: var(--text2); font-size: 0.9rem; transition: color 0.2s; }
.nav-links a:hover { color: var(--emerald); }
.btn { display: inline-block; padding: 0.7rem 1.8rem; border-radius: 8px; font-weight: 600; font-size: 0.95rem; transition: all 0.3s; cursor: pointer; border: none; }
.btn-primary { background: var(--emerald); color: #000; }
.btn-primary:hover { background: var(--emerald-dark); transform: translateY(-2px); box-shadow: 0 8px 30px rgba(16,185,129,0.3); }
.btn-outline { border: 1px solid var(--emerald); color: var(--emerald); background: transparent; }
.btn-outline:hover { background: var(--emerald-glow); }
/* === HERO === */
.hero { min-height: 100vh; display: flex; align-items: center; justify-content: center; text-align: center; padding: 8rem 2rem 4rem; position: relative; }
.hero::before { content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 800px; height: 800px; background: radial-gradient(circle, rgba(16,185,129,0.08) 0%, transparent 70%); pointer-events: none; }
.hero-badge { display: inline-block; padding: 0.4rem 1.2rem; border: 1px solid var(--emerald); border-radius: 50px; font-size: 0.8rem; color: var(--emerald); margin-bottom: 2rem; animation: pulse-border 2s infinite; }
@keyframes pulse-border { 0%,100% { border-color: var(--emerald); } 50% { border-color: rgba(16,185,129,0.3); } }
.hero h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); font-weight: 700; line-height: 1.3; margin-bottom: 1.5rem; }
.hero h1 span { color: var(--emerald); }
.hero p { font-size: 1.2rem; color: var(--text2); max-width: 600px; margin: 0 auto 2.5rem; }
.hero-cta { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.hero-stats { display: flex; gap: 3rem; justify-content: center; margin-top: 4rem; }
.hero-stat { text-align: center; }
.hero-stat-num { font-size: 2rem; font-weight: 700; color: var(--emerald); }
.hero-stat-label { font-size: 0.85rem; color: var(--text2); }
/* === SECTIONS === */
section { padding: 6rem 2rem; }
.container { max-width: 1100px; margin: 0 auto; }
.section-title { text-align: center; margin-bottom: 4rem; }
.section-title h2 { font-size: 2.2rem; font-weight: 700; margin-bottom: 1rem; }
.section-title p { color: var(--text2); max-width: 500px; margin: 0 auto; }
/* === FEATURES === */
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; }
.feature-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 16px; padding: 2rem; transition: all 0.3s; }
.feature-card:hover { border-color: rgba(16,185,129,0.3); transform: translateY(-4px); box-shadow: 0 20px 40px rgba(0,0,0,0.3); }
.feature-icon { width: 48px; height: 48px; background: var(--emerald-glow); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin-bottom: 1.2rem; }
.feature-card h3 { font-size: 1.1rem; margin-bottom: 0.7rem; }
.feature-card p { color: var(--text2); font-size: 0.9rem; }
/* === HOW IT WORKS === */
.steps { display: flex; gap: 2rem; flex-wrap: wrap; justify-content: center; }
.step { flex: 1; min-width: 220px; max-width: 280px; text-align: center; position: relative; }
.step-num { width: 56px; height: 56px; background: linear-gradient(135deg, var(--emerald), var(--emerald-dark)); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 700; color: #000; margin: 0 auto 1.2rem; }
.step h3 { font-size: 1.05rem; margin-bottom: 0.5rem; }
.step p { color: var(--text2); font-size: 0.85rem; }
/* === PRICING === */
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.5rem; }
.price-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 16px; padding: 2rem; text-align: center; position: relative; transition: all 0.3s; }
.price-card:hover { transform: translateY(-4px); }
.price-card.popular { border-color: var(--emerald); box-shadow: 0 0 40px rgba(16,185,129,0.1); }
.popular-badge { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: var(--emerald); color: #000; padding: 0.3rem 1rem; border-radius: 50px; font-size: 0.75rem; font-weight: 700; }
.price-name { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
.price-amount { font-size: 2.5rem; font-weight: 700; margin: 1rem 0; }
.price-amount span { font-size: 0.9rem; color: var(--text2); font-weight: 400; }
.price-features { list-style: none; text-align: right; margin: 1.5rem 0; }
.price-features li { padding: 0.4rem 0; font-size: 0.85rem; color: var(--text2); }
.price-features li::before { content: '✓'; color: var(--emerald); margin-left: 0.5rem; font-weight: 700; }
/* === FAQ === */
.faq-list { max-width: 700px; margin: 0 auto; }
.faq-item { border-bottom: 1px solid var(--border); }
.faq-q { padding: 1.2rem 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
.faq-q::after { content: '+'; font-size: 1.5rem; color: var(--emerald); transition: transform 0.3s; }
.faq-item.open .faq-q::after { transform: rotate(45deg); }
.faq-a { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; color: var(--text2); font-size: 0.9rem; }
.faq-item.open .faq-a { max-height: 200px; padding-bottom: 1.2rem; }
/* === CTA === */
.cta-section { text-align: center; background: linear-gradient(135deg, rgba(16,185,129,0.05), rgba(16,185,129,0.02)); border: 1px solid rgba(16,185,129,0.1); border-radius: 24px; padding: 4rem 2rem; margin: 0 auto; max-width: 800px; }
.cta-section h2 { font-size: 2rem; margin-bottom: 1rem; }
.cta-section p { color: var(--text2); margin-bottom: 2rem; }
/* === FOOTER === */
footer { padding: 3rem 2rem; border-top: 1px solid var(--border); text-align: center; color: var(--text2); font-size: 0.85rem; }
/* === RESPONSIVE === */
@media (max-width: 768px) {
.nav-links { display: none; }
.hero-stats { flex-direction: column; gap: 1.5rem; }
.pricing-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav class="nav">
<div class="nav-logo">مُصادَق</div>
<div class="nav-links">
<a href="#features">المميزات</a>
<a href="#how">كيف يعمل</a>
<a href="#pricing">الباقات</a>
<a href="#faq">الأسئلة</a>
<a href="/login.php" class="btn btn-outline">دخول</a>
<a href="/register.php" class="btn btn-primary">ابدأ مجاناً</a>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div>
<div class="hero-badge">🚀 متوافق 100% مع منظومة جوفوترا الأردنية</div>
<h1>صوّر الفاتورة<br><span>والذكاء الاصطناعي يكمل الباقي</span></h1>
<p>منصة أتمتة الفواتير الإلكترونية للمحاسبين ومكاتب المحاسبة. استخراج البيانات تلقائياً، تدقيق ضريبي ذكي، وإرسال مباشر لجوفوترا من صورة واحدة.</p>
<div class="hero-cta">
<a href="/register.php" class="btn btn-primary">ابدأ تجربة مجانية </a>
<a href="#how" class="btn btn-outline">شاهد كيف يعمل</a>
</div>
<div class="hero-stats">
<div class="hero-stat"><div class="hero-stat-num">3 ثوانٍ</div><div class="hero-stat-label">لاستخراج بيانات الفاتورة</div></div>
<div class="hero-stat"><div class="hero-stat-num">99%</div><div class="hero-stat-label">دقة الذكاء الاصطناعي</div></div>
<div class="hero-stat"><div class="hero-stat-num">0 دينار</div><div class="hero-stat-label">للبدء بدون بطاقة</div></div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features">
<div class="container">
<div class="section-title">
<h2>ليش مُصادَق مختلف؟</h2>
<p>مش مجرد برنامج فوترة مساعدك المالي بالذكاء الاصطناعي</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🤖</div>
<h3>استخراج ذكي بالـ AI</h3>
<p>صوّر الفاتورة أو ارفع PDF الذكاء الاصطناعي يستخرج كل البيانات تلقائياً: اسم المورد، المبلغ، الضريبة، وبنود الفاتورة.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔗</div>
<h3>ربط مباشر بجوفوترا</h3>
<p>إرسال الفاتورة بصيغة UBL 2.1 لمنظومة جوفوترا الأردنية بضغطة واحدة، مع رمز QR تلقائي.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>تدقيق ضريبي ذكي</h3>
<p>يراجع نسب الضريبة تلقائياً حسب جدول الضرائب الأردني ويُنبّهك قبل الإرسال إذا وجد خطأ.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🏢</div>
<h3>إدارة شركات متعددة</h3>
<p>أدِر حتى 25 شركة من حساب واحد. لكل شركة إعدادات مستقلة وربط منفصل بجوفوترا.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📱</div>
<h3>تطبيق هاتف ذكي</h3>
<p>صوّر فواتير في الميدان بدون إنترنت. التطبيق يخزّنها ويرفعها تلقائياً عند توفر الاتصال.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>أمان بمعايير بنكية</h3>
<p>تشفير AES-256-GCM لكل البيانات، مصادقة ثنائية، وفصل كامل لبيانات كل مكتب محاسبي.</p>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how" style="background: var(--bg2);">
<div class="container">
<div class="section-title">
<h2>كيف يعمل مُصادَق؟</h2>
<p>من الصورة للإرسال الرسمي بـ 3 خطوات</p>
</div>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<h3>صوّر أو ارفع</h3>
<p>صوّر الفاتورة من الهاتف أو ارفع ملف PDF/صورة من الكمبيوتر</p>
</div>
<div class="step">
<div class="step-num">2</div>
<h3>الـ AI يستخرج ويدقق</h3>
<p>الذكاء الاصطناعي يستخرج البيانات ويتحقق من صحة الضرائب تلقائياً</p>
</div>
<div class="step">
<div class="step-num">3</div>
<h3>اعتمد وأرسل</h3>
<p>راجع البيانات واضغط إرسال الفاتورة تصل جوفوترا بصيغة UBL 2.1</p>
</div>
</div>
</div>
</section>
<!-- PRICING -->
<section id="pricing">
<div class="container">
<div class="section-title">
<h2>باقات تناسب كل حجم</h2>
<p>ابدأ مجاناً وترقّى مع نمو عملك</p>
</div>
<div class="pricing-grid">
<div class="price-card">
<div class="price-name">مجانية</div>
<div class="price-amount">0 <span>دينار/شهر</span></div>
<ul class="price-features">
<li>شركة واحدة</li>
<li>15 فاتورة شهرياً</li>
<li>استخراج بالذكاء الاصطناعي</li>
<li>ربط مع جوفوترا</li>
</ul>
<a href="/register.php" class="btn btn-outline" style="width:100%;">ابدأ مجاناً</a>
</div>
<div class="price-card">
<div class="price-name">أساسية</div>
<div class="price-amount">15 <span>دينار/شهر</span></div>
<ul class="price-features">
<li>حتى 3 شركات</li>
<li>100 فاتورة شهرياً</li>
<li>3 مستخدمين</li>
<li>تقارير شهرية</li>
</ul>
<a href="/register.php" class="btn btn-outline" style="width:100%;">اشترك الآن</a>
</div>
<div class="price-card popular">
<div class="popular-badge">الأكثر طلباً</div>
<div class="price-name">مكتبية</div>
<div class="price-amount">45 <span>دينار/شهر</span></div>
<ul class="price-features">
<li>حتى 10 شركات</li>
<li>500 فاتورة شهرياً</li>
<li>10 مستخدمين</li>
<li>تقارير متقدمة + تصدير</li>
<li>دعم فني بالأولوية</li>
</ul>
<a href="/register.php" class="btn btn-primary" style="width:100%;">اشترك الآن</a>
</div>
<div class="price-card">
<div class="price-name">احترافية</div>
<div class="price-amount">99 <span>دينار/شهر</span></div>
<ul class="price-features">
<li>حتى 25 شركة</li>
<li>2,000 فاتورة شهرياً</li>
<li>تدقيق ذكي بالـ AI</li>
<li>API كامل</li>
<li>مدير حساب مخصص</li>
</ul>
<a href="/register.php" class="btn btn-outline" style="width:100%;">اشترك الآن</a>
</div>
</div>
</div>
</section>
<!-- FAQ -->
<section id="faq" style="background: var(--bg2);">
<div class="container">
<div class="section-title">
<h2>أسئلة شائعة</h2>
</div>
<div class="faq-list">
<div class="faq-item" onclick="this.classList.toggle('open')">
<div class="faq-q">هل مُصادَق متوافق مع منظومة جوفوترا؟</div>
<div class="faq-a">نعم، مُصادَق يولّد فواتير بصيغة UBL 2.1 المتوافقة كلياً مع متطلبات دائرة ضريبة الدخل والمبيعات الأردنية، ويرسلها مباشرة عبر API جوفوترا.</div>
</div>
<div class="faq-item" onclick="this.classList.toggle('open')">
<div class="faq-q">هل يمكنني استخدامه بدون إنترنت؟</div>
<div class="faq-a">نعم، تطبيق الهاتف يعمل بدون إنترنت. يمكنك تصوير الفواتير وتخزينها محلياً، وعند توفر الاتصال يتم رفعها تلقائياً.</div>
</div>
<div class="faq-item" onclick="this.classList.toggle('open')">
<div class="faq-q">كيف يعمل الذكاء الاصطناعي في استخراج البيانات؟</div>
<div class="faq-a">نستخدم نماذج Gemini المتقدمة من Google لتحليل صور الفواتير واستخراج كل البيانات (اسم المورد، الرقم الضريبي، المبالغ، بنود الفاتورة) بدقة تصل لـ 99%.</div>
</div>
<div class="faq-item" onclick="this.classList.toggle('open')">
<div class="faq-q">هل بياناتي آمنة؟</div>
<div class="faq-a">نستخدم تشفير AES-256-GCM لكل البيانات الحساسة، مع فصل كامل لبيانات كل مكتب محاسبي (Multi-Tenancy). بياناتك لا يراها أحد غيرك.</div>
</div>
<div class="faq-item" onclick="this.classList.toggle('open')">
<div class="faq-q">هل يمكنني إلغاء اشتراكي في أي وقت؟</div>
<div class="faq-a">نعم، يمكنك إلغاء أو تغيير باقتك في أي وقت. بياناتك تبقى محفوظة لمدة 90 يوماً بعد الإلغاء.</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section>
<div class="container">
<div class="cta-section">
<h2>جاهز تختصر ساعات من العمل اليدوي؟</h2>
<p>ابدأ مجاناً الآن بدون بطاقة ائتمانية، بدون التزام.</p>
<a href="/register.php" class="btn btn-primary">ابدأ تجربتك المجانية </a>
</div>
</div>
</section>
<!-- FOOTER -->
<footer>
<div class="container">
<p style="margin-bottom: 0.5rem; font-size: 1.1rem; color: var(--emerald); font-weight: 600;">مُصادَق</p>
<p>منصة أتمتة الفواتير الإلكترونية صُنع في الأردن 🇯🇴</p>
<p style="margin-top: 1rem;">© 2026 مُصادَق. جميع الحقوق محفوظة.</p>
</div>
</footer>
</body>
</html>

View File

@@ -1,11 +1,33 @@
<?php
/**
* Quick Encryption Tool
* Quick Encryption Tool (Development Only — Protected)
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
// SECURITY: Block access in production
if (env('APP_ENV', 'production') === 'production') {
http_response_code(404);
echo 'Not Found';
exit;
}
// SECURITY: Require super_admin authentication
try {
$decoded = AuthMiddleware::check();
if (($decoded['role'] ?? '') !== 'super_admin') {
http_response_code(403);
echo 'Forbidden';
exit;
}
} catch (\Throwable $e) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
$input = $_GET['text'] ?? '';
$encrypted = $input ? Encryption::encrypt($input) : '';