Update: 2026-05-08 04:58:23
This commit is contained in:
@@ -13,12 +13,36 @@ final class Validator
|
|||||||
{
|
{
|
||||||
$errors = [];
|
$errors = [];
|
||||||
foreach ($rules as $field => $rule) {
|
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.";
|
$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.";
|
$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;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|||||||
155
app/Services/GamificationService.php
Normal file
155
app/Services/GamificationService.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,17 +158,87 @@ class NotificationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OAuth2 Access Token for Firebase (Cache this in production!)
|
* Get OAuth2 Access Token for Firebase using Service Account JWT
|
||||||
* Note: This requires a JWT library or manual signing.
|
* Self-contained: no external libraries needed.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
private function getAccessToken(): ?string
|
private function getAccessToken(): ?string
|
||||||
{
|
{
|
||||||
// This is a complex part that usually requires 'google/auth' library.
|
// Check cache first (token is valid for 1 hour, we cache for 50 min)
|
||||||
// For now, I will return null and tell the user they need to install google/auth via composer
|
$cacheFile = STORAGE_PATH . '/cache/fcm_token.json';
|
||||||
// OR I can write a minimal JWT signer for Google Auth if they don't want composer.
|
if (file_exists($cacheFile)) {
|
||||||
error_log("[NotificationService] OAuth2 Token generation needs google/auth library.");
|
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||||
return null;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
163
app/Services/SmartNotifications.php
Normal file
163
app/Services/SmartNotifications.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ define('STORAGE_PATH', ROOT_PATH . '/storage');
|
|||||||
// 2. Load Environment & Helpers FIRST
|
// 2. Load Environment & Helpers FIRST
|
||||||
require_once APP_PATH . '/bootstrap/env.php';
|
require_once APP_PATH . '/bootstrap/env.php';
|
||||||
require_once APP_PATH . '/helpers/helpers.php';
|
require_once APP_PATH . '/helpers/helpers.php';
|
||||||
|
require_once APP_PATH . '/helpers/pagination.php';
|
||||||
|
|
||||||
// Load Composer Autoloader
|
// Load Composer Autoloader
|
||||||
$vendorAutoload = ROOT_PATH . '/vendor/autoload.php';
|
$vendorAutoload = ROOT_PATH . '/vendor/autoload.php';
|
||||||
@@ -25,8 +26,7 @@ $dirs = ['/cache', '/logs', '/invoices', '/exports'];
|
|||||||
foreach ($dirs as $d) {
|
foreach ($dirs as $d) {
|
||||||
$path = STORAGE_PATH . $d;
|
$path = STORAGE_PATH . $d;
|
||||||
if (!is_dir($path)) {
|
if (!is_dir($path)) {
|
||||||
mkdir($path, 0777, true);
|
mkdir($path, 0755, true);
|
||||||
chmod($path, 0777);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,27 @@ header("X-Content-Type-Options: nosniff");
|
|||||||
header("X-Frame-Options: SAMEORIGIN");
|
header("X-Frame-Options: SAMEORIGIN");
|
||||||
header("X-XSS-Protection: 1; mode=block");
|
header("X-XSS-Protection: 1; mode=block");
|
||||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
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)
|
// 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility)
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
|
|||||||
@@ -38,3 +38,19 @@ if (!function_exists('dd')) {
|
|||||||
die();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
59
app/helpers/pagination.php
Normal file
59
app/helpers/pagination.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/modules_app/academy/articles.php
Normal file
93
app/modules_app/academy/articles.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Musadaq Academy — Educational Content
|
||||||
|
* GET /v1/academy/articles
|
||||||
|
* GET /v1/academy/articles?category=tax
|
||||||
|
*
|
||||||
|
* Returns curated accounting and tax educational articles.
|
||||||
|
* Content is stored in-code for MVP, can be migrated to DB later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
$category = $_GET['category'] ?? null;
|
||||||
|
$search = $_GET['search'] ?? null;
|
||||||
|
|
||||||
|
// In-code content library (MVP — migrate to DB when content grows)
|
||||||
|
$articles = [
|
||||||
|
[
|
||||||
|
'id' => 'tax-101',
|
||||||
|
'category' => 'tax',
|
||||||
|
'title' => 'دليل ضريبة المبيعات الأردنية الشامل',
|
||||||
|
'summary' => 'كل ما تحتاج معرفته عن نسب ضريبة المبيعات في الأردن: العامة (16%)، المخفضة (4% و 8%)، والمعفاة.',
|
||||||
|
'content' => "## نسب ضريبة المبيعات في الأردن\n\n### النسبة العامة: 16%\nتُطبق على معظم السلع والخدمات.\n\n### النسبة المخفضة: 4%\n- الأدوية\n- المستلزمات الطبية\n\n### النسبة المخفضة: 8%\n- الخدمات السياحية\n- بعض المواد الغذائية المصنعة\n\n### معفاة من الضريبة (0%)\n- الخبز\n- الحليب\n- التعليم\n- الخدمات الصحية\n\n> ملاحظة: هذه المعلومات للإرشاد فقط. راجع دائرة ضريبة الدخل والمبيعات للتفاصيل الرسمية.",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '🏛️',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'jofotara-guide',
|
||||||
|
'category' => 'jofotara',
|
||||||
|
'title' => 'كيف تربط شركتك بمنظومة جوفوترا',
|
||||||
|
'summary' => 'خطوات تسجيل شركتك والحصول على Client ID و Secret Key من منظومة الفوترة الإلكترونية.',
|
||||||
|
'content' => "## خطوات الربط بجوفوترا\n\n### 1. التسجيل في المنظومة\n- ادخل على portal.jofotara.gov.jo\n- سجّل بالرقم الضريبي لشركتك\n\n### 2. الحصول على المفاتيح\n- من لوحة التحكم، اختر \"إدارة التطبيقات\"\n- أنشئ تطبيق جديد\n- انسخ Client ID و Secret Key\n\n### 3. الربط في مُصادَق\n- افتح إعدادات الشركة\n- الصق Client ID و Secret Key\n- اضغط \"اختبار الاتصال\"\n\n> بعد الربط، يمكنك إرسال الفواتير لجوفوترا بضغطة واحدة!",
|
||||||
|
'reading_time' => 4,
|
||||||
|
'icon' => '🔗',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'invoice-types',
|
||||||
|
'category' => 'invoicing',
|
||||||
|
'title' => 'أنواع الفواتير الإلكترونية في الأردن',
|
||||||
|
'summary' => 'الفرق بين فاتورة المبيعات، الإشعار الدائن، والإشعار المدين حسب UBL 2.1.',
|
||||||
|
'content' => "## أنواع الفواتير\n\n### 1. فاتورة مبيعات (Invoice)\nالنوع الأساسي — تُصدر عند بيع سلعة أو خدمة.\n\n### 2. إشعار دائن (Credit Note)\nيُصدر لتعديل فاتورة سابقة بالتخفيض (مرتجعات أو خصومات).\n\n### 3. إشعار مدين (Debit Note)\nيُصدر لتعديل فاتورة سابقة بالزيادة.\n\n### متطلبات UBL 2.1\n- كل فاتورة يجب أن تحتوي على رقم ضريبي صحيح\n- التاريخ بصيغة ISO\n- تفصيل البنود مع الكمية والسعر",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '📄',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'ai-tips',
|
||||||
|
'category' => 'tips',
|
||||||
|
'title' => 'نصائح للحصول على أفضل نتائج من الذكاء الاصطناعي',
|
||||||
|
'summary' => 'كيف تصوّر الفاتورة لتحصل على استخراج دقيق بنسبة 99%.',
|
||||||
|
'content' => "## نصائح التصوير\n\n### ✅ افعل:\n- صوّر الفاتورة كاملة مع الحواف\n- تأكد من الإضاءة الجيدة\n- ضع الفاتورة على سطح مسطح\n- صوّر من الأعلى مباشرة (لا بزاوية)\n\n### ❌ لا تفعل:\n- لا تصوّر جزء من الفاتورة فقط\n- لا تصوّر فاتورة مطوية أو مجعدة\n- لا تصوّر في إضاءة خافتة\n- لا ترفع صور أقل من 300x300 بكسل\n\n### 💡 نصيحة إضافية:\nاستخدم ميزة الـ Batch Scan لتصوير عدة فواتير دفعة واحدة!",
|
||||||
|
'reading_time' => 2,
|
||||||
|
'icon' => '💡',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'security-guide',
|
||||||
|
'category' => 'security',
|
||||||
|
'title' => 'كيف يحمي مُصادَق بياناتك',
|
||||||
|
'summary' => 'نظرة على تقنيات التشفير والحماية المستخدمة في المنصة.',
|
||||||
|
'content' => "## حماية بياناتك\n\n### تشفير AES-256-GCM\nكل البيانات الحساسة (أسماء، أرقام ضريبية، مفاتيح API) مشفرة بأقوى معيار تشفير.\n\n### فصل البيانات (Multi-Tenancy)\nكل مكتب محاسبي معزول تماماً — لا يمكن لأي مكتب رؤية بيانات مكتب آخر.\n\n### مصادقة ثنائية\nتسجيل الدخول يتطلب OTP عبر واتساب بالإضافة لكلمة المرور.\n\n### HMAC Signature\nكل طلب API يتم التحقق من سلامته عبر توقيع رقمي.",
|
||||||
|
'reading_time' => 3,
|
||||||
|
'icon' => '🔒',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if ($category) {
|
||||||
|
$articles = array_values(array_filter($articles, fn($a) => $a['category'] === $category));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($search) {
|
||||||
|
$searchLower = mb_strtolower($search);
|
||||||
|
$articles = array_values(array_filter($articles, fn($a) =>
|
||||||
|
str_contains(mb_strtolower($a['title']), $searchLower) ||
|
||||||
|
str_contains(mb_strtolower($a['summary']), $searchLower)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = [
|
||||||
|
['key' => 'tax', 'name' => 'ضرائب', 'icon' => '🏛️'],
|
||||||
|
['key' => 'jofotara', 'name' => 'جوفوترا', 'icon' => '🔗'],
|
||||||
|
['key' => 'invoicing', 'name' => 'فوترة', 'icon' => '📄'],
|
||||||
|
['key' => 'tips', 'name' => 'نصائح', 'icon' => '💡'],
|
||||||
|
['key' => 'security', 'name' => 'أمان', 'icon' => '🔒'],
|
||||||
|
];
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'articles' => $articles,
|
||||||
|
'categories' => $categories,
|
||||||
|
'total' => count($articles),
|
||||||
|
], 'أكاديمية مُصادَق');
|
||||||
@@ -51,5 +51,5 @@ try {
|
|||||||
json_success(null, 'تم تخصيص المستخدم للشركة بنجاح');
|
json_success(null, 'تم تخصيص المستخدم للشركة بنجاح');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('حدث خطأ أثناء التخصيص: ' . $e->getMessage(), 500);
|
safe_error($e, 'assignments/create', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,5 +37,5 @@ try {
|
|||||||
json_success($assignments);
|
json_success($assignments);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error: ' . $e->getMessage(), 500);
|
safe_error($e, 'assignments/index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,5 +117,5 @@ try {
|
|||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("Audit log error: " . $e->getMessage());
|
error_log("Audit log error: " . $e->getMessage());
|
||||||
json_error('خطأ في جلب سجل النشاط: ' . $e->getMessage(), 500);
|
safe_error($e, 'audit/index', 'خطأ في جلب سجل النشاط.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ try {
|
|||||||
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('Internal Server Error: ' . $e->getMessage(), 500);
|
safe_error($e, 'auth/mobile_request_otp');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
app/modules_app/chatbot/ask.php
Normal file
88
app/modules_app/chatbot/ask.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Accounting Chatbot — "اسأل مُصادَق"
|
||||||
|
* POST /v1/chatbot/ask
|
||||||
|
* Body: { "question": "كم ضريبة المبيعات على الخدمات الرقمية؟" }
|
||||||
|
*
|
||||||
|
* AI-powered chatbot that answers accounting & tax questions
|
||||||
|
* with context from the user's own data when relevant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$question = trim($data['question'] ?? '');
|
||||||
|
|
||||||
|
if (empty($question) || mb_strlen($question) < 3) {
|
||||||
|
json_error('يرجى كتابة سؤالك (3 أحرف على الأقل)', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($question) > 500) {
|
||||||
|
json_error('السؤال طويل جداً (الحد 500 حرف)', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Gather user context (last month stats)
|
||||||
|
$contextStmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as total_revenue,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as total_tax
|
||||||
|
FROM invoices
|
||||||
|
WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
");
|
||||||
|
$contextStmt->execute([$tenantId]);
|
||||||
|
$context = $contextStmt->fetch();
|
||||||
|
|
||||||
|
$companyStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$companyStmt->execute([$tenantId]);
|
||||||
|
$companyCount = (int)$companyStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 2. Build AI prompt
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
أنت "مُصادَق" — مساعد محاسبي ذكي متخصص في المحاسبة والضرائب الأردنية.
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
1. أجب بالعربية دائماً وبشكل مختصر ومفيد
|
||||||
|
2. إذا كان السؤال عن ضرائب أردنية، استخدم نسب ضريبة المبيعات الأردنية (16% عامة، 4% و8% مخفضة، 0% معفاة)
|
||||||
|
3. إذا كان السؤال غير محاسبي، قل "أنا متخصص بالمحاسبة والضرائب فقط"
|
||||||
|
4. لا تعطِ نصائح قانونية نهائية — انصح بمراجعة محاسب قانوني للحالات المعقدة
|
||||||
|
5. إذا كان السؤال يتعلق ببيانات المستخدم، استخدم السياق المتاح
|
||||||
|
|
||||||
|
سياق المستخدم (آخر 30 يوم):
|
||||||
|
- عدد الفواتير: {$context['total_invoices']}
|
||||||
|
- إجمالي الإيرادات: {$context['total_revenue']} دينار
|
||||||
|
- إجمالي الضريبة: {$context['total_tax']} دينار
|
||||||
|
- عدد الشركات: {$companyCount}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$aiResponse = AI::chat($systemPrompt, $question, $tenantId);
|
||||||
|
|
||||||
|
if (!$aiResponse) {
|
||||||
|
json_error('عذراً، لم أتمكن من معالجة سؤالك. حاول مرة أخرى.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Log the conversation
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO chatbot_history (id, user_id, tenant_id, question, answer, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, ?, ?, NOW())
|
||||||
|
")->execute([$userId, $tenantId, $question, $aiResponse]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'answer' => $aiResponse,
|
||||||
|
'question' => $question,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
], 'إجابة مُصادَق');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'chatbot/ask', 'حدث خطأ في المساعد الذكي.');
|
||||||
|
}
|
||||||
29
app/modules_app/chatbot/history.php
Normal file
29
app/modules_app/chatbot/history.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Chatbot History
|
||||||
|
* GET /v1/chatbot/history
|
||||||
|
* Returns user's recent chatbot conversations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$pagination = paginate_params(20, 50);
|
||||||
|
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM chatbot_history WHERE user_id = ?");
|
||||||
|
$countStmt->execute([$decoded['user_id']]);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, question, answer, created_at
|
||||||
|
FROM chatbot_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute([$decoded['user_id']]);
|
||||||
|
|
||||||
|
json_paginated($stmt->fetchAll(), $total, $pagination);
|
||||||
@@ -61,5 +61,5 @@ try {
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("JoFotara Connection Error: " . $e->getMessage());
|
error_log("JoFotara Connection Error: " . $e->getMessage());
|
||||||
json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500);
|
safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,6 @@ try {
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
error_log("[companies/create] Error: " . $e->getMessage());
|
||||||
|
json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,5 +64,5 @@ try {
|
|||||||
json_success($companies);
|
json_success($companies);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Companies List: ' . $e->getMessage(), 500);
|
safe_error($e, 'companies/index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,5 +76,5 @@ try {
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("AI Usage Stats Error: " . $e->getMessage() . " | " . $e->getTraceAsString());
|
error_log("AI Usage Stats Error: " . $e->getMessage() . " | " . $e->getTraceAsString());
|
||||||
json_error('خطأ في جلب إحصائيات AI: ' . $e->getMessage(), 500);
|
safe_error($e, 'dashboard/ai_usage', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ try {
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if (isset($db)) $db->rollBack();
|
if (isset($db)) $db->rollBack();
|
||||||
json_error('فشل معالجة ملف الاكسل: ' . $e->getMessage(), 500);
|
safe_error($e, 'excel/import', 'فشل معالجة ملف الإكسل.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
15
app/modules_app/gamification/profile.php
Normal file
15
app/modules_app/gamification/profile.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gamification Profile
|
||||||
|
* GET /v1/gamification/profile
|
||||||
|
* Returns user's points, level, badges, and progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Services\GamificationService;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
|
||||||
|
$profile = GamificationService::getProfile($decoded['user_id'], $decoded['tenant_id']);
|
||||||
|
|
||||||
|
json_success($profile, 'ملفك التنافسي');
|
||||||
@@ -123,8 +123,21 @@ try {
|
|||||||
'api_success' => $apiResponse['success'],
|
'api_success' => $apiResponse['success'],
|
||||||
], $decoded);
|
], $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) {
|
} catch (\Exception $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
error_log("JoFotara Approve Error: " . $e->getMessage());
|
error_log("JoFotara Approve Error: " . $e->getMessage());
|
||||||
json_error('خطأ غير متوقع: ' . $e->getMessage(), 500);
|
safe_error($e, 'invoices/approve');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Invoices List Endpoint (Role-Based & Tenant-Aware)
|
* Invoices List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
@@ -16,26 +16,17 @@ $userId = $decoded['user_id'];
|
|||||||
$role = $decoded['role'];
|
$role = $decoded['role'];
|
||||||
|
|
||||||
try {
|
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') {
|
if ($role === 'super_admin') {
|
||||||
// Super Admin sees ALL invoices
|
$where = '1=1';
|
||||||
$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
|
|
||||||
");
|
|
||||||
} elseif ($role === 'admin') {
|
} elseif ($role === 'admin') {
|
||||||
// Admin sees all invoices in THEIR tenant
|
$where = 'i.tenant_id = ?';
|
||||||
$stmt = $db->prepare("
|
$params = [$tenantId];
|
||||||
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]);
|
|
||||||
} else {
|
} else {
|
||||||
// Accountant/Viewer: Filter by assigned companies
|
// Accountant/Viewer: Filter by assigned companies
|
||||||
$stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1");
|
$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);
|
$assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
if (empty($assignedCompanyIds)) {
|
if (empty($assignedCompanyIds)) {
|
||||||
json_success([]);
|
json_paginated([], 0, $pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?'));
|
||||||
$stmt = $db->prepare("
|
$where = "i.company_id IN ($placeholders)";
|
||||||
SELECT i.*, c.name as company_name
|
$params = $assignedCompanyIds;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
// 3. Decrypt sensitive fields for display (Robustly)
|
// 5. Decrypt sensitive fields
|
||||||
$dec = function($val) {
|
$dec = function($val) {
|
||||||
if (empty($val)) return '';
|
if (empty($val)) return '';
|
||||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
$result = Encryption::decrypt((string)$val);
|
||||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,12 +102,8 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($invoices)) {
|
json_paginated($invoices, $total, $pagination);
|
||||||
error_log("INVOICES LIST: No invoices found for role: $role, tenant_id: $tenantId");
|
|
||||||
}
|
|
||||||
|
|
||||||
json_success($invoices);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Invoices List: ' . $e->getMessage(), 500);
|
safe_error($e, 'invoices/index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ if ($result['success']) {
|
|||||||
'jofotara_uuid' => $result['uuid'],
|
'jofotara_uuid' => $result['uuid'],
|
||||||
], $decoded);
|
], $decoded);
|
||||||
|
|
||||||
|
\App\Services\SmartNotifications::jofotaraSuccess($tenantId, $userId, $invoiceId, $result['uuid']);
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'uuid' => $result['uuid'],
|
'uuid' => $result['uuid'],
|
||||||
'qr_code' => $qrBase64,
|
'qr_code' => $qrBase64,
|
||||||
@@ -158,5 +160,7 @@ if ($result['success']) {
|
|||||||
'error' => $result['error'] ?? 'Unknown',
|
'error' => $result['error'] ?? 'Unknown',
|
||||||
], $decoded);
|
], $decoded);
|
||||||
|
|
||||||
|
\App\Services\SmartNotifications::jofotaraRejected($tenantId, $userId, $invoiceId, $result['error'] ?? 'خطأ غير محدد');
|
||||||
|
|
||||||
json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422);
|
json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ try {
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
error_log("Invoice Update Error: " . $e->getMessage());
|
error_log("Invoice Update Error: " . $e->getMessage());
|
||||||
json_error('فشل تحديث الفاتورة: ' . $e->getMessage(), 500);
|
safe_error($e, 'invoices/update', 'فشل تحديث الفاتورة.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,12 @@ try {
|
|||||||
|
|
||||||
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) {
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
if (!mkdir($dir, 0777, true)) {
|
if (!mkdir($dir, 0755, true)) {
|
||||||
json_error('فشل في إنشاء مجلد التخزين: ' . $dir, 500);
|
error_log('Failed to create storage directory: ' . $dir);
|
||||||
|
json_error('فشل في تجهيز مساحة التخزين', 500);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
chmod($dir, 0777);
|
chmod($dir, 0755);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +199,8 @@ try {
|
|||||||
|
|
||||||
// --- INCREMENT QUOTA ---
|
// --- INCREMENT QUOTA ---
|
||||||
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
||||||
|
\App\Services\SmartNotifications::checkQuotaWarning($tenantId);
|
||||||
|
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
|
||||||
// -----------------------
|
// -----------------------
|
||||||
|
|
||||||
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
|
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
|
||||||
@@ -207,14 +210,14 @@ try {
|
|||||||
if (isset($db) && $db->inTransaction()) {
|
if (isset($db) && $db->inTransaction()) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
}
|
}
|
||||||
error_log("Database Error: " . $e->getMessage());
|
error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
json_error('حدث خطأ في قاعدة البيانات: ' . $e->getMessage(), 500);
|
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500);
|
||||||
exit;
|
exit;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if (isset($db) && $db->inTransaction()) {
|
if (isset($db) && $db->inTransaction()) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
}
|
}
|
||||||
error_log("Critical Error: " . $e->getMessage() . " on line " . $e->getLine());
|
error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine());
|
||||||
json_error('خطأ برمجي حرج: ' . $e->getMessage() . ' في السطر ' . $e->getLine(), 500);
|
json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
74
app/modules_app/marketplace/listings.php
Normal file
74
app/modules_app/marketplace/listings.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Marketplace — Accountant Directory & Service Listings
|
||||||
|
* GET /v1/marketplace/listings
|
||||||
|
* GET /v1/marketplace/listings?city=amman&specialty=tax
|
||||||
|
*
|
||||||
|
* Public directory where accounting offices can list their services
|
||||||
|
* and businesses can find accountants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$pagination = paginate_params(20, 50);
|
||||||
|
|
||||||
|
$city = $_GET['city'] ?? null;
|
||||||
|
$specialty = $_GET['specialty'] ?? null;
|
||||||
|
$search = $_GET['search'] ?? null;
|
||||||
|
|
||||||
|
$where = "ml.is_active = 1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($city) {
|
||||||
|
$where .= " AND ml.city = ?";
|
||||||
|
$params[] = $city;
|
||||||
|
}
|
||||||
|
if ($specialty) {
|
||||||
|
$where .= " AND ml.specialty = ?";
|
||||||
|
$params[] = $specialty;
|
||||||
|
}
|
||||||
|
if ($search) {
|
||||||
|
$where .= " AND (ml.office_name LIKE ? OR ml.description LIKE ?)";
|
||||||
|
$params[] = "%{$search}%";
|
||||||
|
$params[] = "%{$search}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Count
|
||||||
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM marketplace_listings ml WHERE {$where}");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Fetch
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT ml.*, t.name as tenant_name
|
||||||
|
FROM marketplace_listings ml
|
||||||
|
LEFT JOIN tenants t ON ml.tenant_id = t.id
|
||||||
|
WHERE {$where}
|
||||||
|
ORDER BY ml.is_featured DESC, ml.rating DESC, ml.created_at DESC
|
||||||
|
LIMIT {$pagination['limit']} OFFSET {$pagination['offset']}
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$listings = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Decrypt names
|
||||||
|
foreach ($listings as &$l) {
|
||||||
|
if (!empty($l['tenant_name'])) {
|
||||||
|
$dec = Encryption::decrypt($l['tenant_name']);
|
||||||
|
$l['tenant_name'] = ($dec !== false && $dec !== null) ? $dec : $l['tenant_name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cities = ['amman' => 'عمّان', 'irbid' => 'إربد', 'zarqa' => 'الزرقاء', 'aqaba' => 'العقبة', 'salt' => 'السلط', 'madaba' => 'مأدبا', 'karak' => 'الكرك', 'other' => 'أخرى'];
|
||||||
|
$specialties = ['tax' => 'ضرائب', 'audit' => 'تدقيق', 'bookkeeping' => 'مسك دفاتر', 'payroll' => 'رواتب', 'consulting' => 'استشارات', 'general' => 'عام'];
|
||||||
|
|
||||||
|
json_paginated($listings, $total, $pagination, 'سوق المحاسبين');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'marketplace/listings', 'حدث خطأ في تحميل القوائم.');
|
||||||
|
}
|
||||||
63
app/modules_app/marketplace/my_listing.php
Normal file
63
app/modules_app/marketplace/my_listing.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Marketplace — Create/Update My Listing
|
||||||
|
* POST /v1/marketplace/my-listing
|
||||||
|
* Body: { "office_name": "...", "city": "amman", "specialty": "tax", "description": "...", "phone": "...", "email": "..." }
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\Validator;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
|
$decoded = RoleMiddleware::require(['super_admin', 'admin']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$data = input();
|
||||||
|
|
||||||
|
$errors = Validator::validate($data, [
|
||||||
|
'office_name' => 'required',
|
||||||
|
'city' => 'required',
|
||||||
|
'specialty' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
json_error('بيانات ناقصة', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if listing exists
|
||||||
|
$existing = $db->prepare("SELECT id FROM marketplace_listings WHERE tenant_id = ? LIMIT 1");
|
||||||
|
$existing->execute([$tenantId]);
|
||||||
|
$row = $existing->fetch();
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
// Update
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE marketplace_listings SET
|
||||||
|
office_name = ?, city = ?, specialty = ?, description = ?,
|
||||||
|
contact_phone = ?, contact_email = ?, updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
")->execute([
|
||||||
|
$data['office_name'], $data['city'], $data['specialty'],
|
||||||
|
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '',
|
||||||
|
$tenantId
|
||||||
|
]);
|
||||||
|
json_success(['id' => $row['id']], 'تم تحديث القائمة بنجاح');
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
$id = Database::generateUuid();
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO marketplace_listings (id, tenant_id, office_name, city, specialty, description, contact_phone, contact_email, is_active, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW())
|
||||||
|
")->execute([
|
||||||
|
$id, $tenantId, $data['office_name'], $data['city'], $data['specialty'],
|
||||||
|
$data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? ''
|
||||||
|
]);
|
||||||
|
json_success(['id' => $id], 'تم إضافة مكتبك للسوق بنجاح! 🎉');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'marketplace/my-listing', 'حدث خطأ في حفظ القائمة.');
|
||||||
|
}
|
||||||
86
app/modules_app/referral/apply.php
Normal file
86
app/modules_app/referral/apply.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Apply Referral Code During Registration
|
||||||
|
* POST /v1/referral/apply
|
||||||
|
* Body: { "referral_code": "MSQ-ABC123" }
|
||||||
|
*
|
||||||
|
* Called during registration to link a new user to their referrer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
$code = $data['referral_code'] ?? null;
|
||||||
|
|
||||||
|
if (!$code) {
|
||||||
|
json_error('رمز الإحالة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Validate the referral code
|
||||||
|
$stmt = $db->prepare("SELECT * FROM referral_codes WHERE code = ? LIMIT 1");
|
||||||
|
$stmt->execute([$code]);
|
||||||
|
$referralCode = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$referralCode) {
|
||||||
|
json_error('رمز الإحالة غير صالح', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-referral
|
||||||
|
if ($referralCode['user_id'] === $userId) {
|
||||||
|
json_error('لا يمكنك استخدام رمز الإحالة الخاص بك', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already used a referral
|
||||||
|
$checkStmt = $db->prepare("SELECT id FROM referrals WHERE referred_id = ? LIMIT 1");
|
||||||
|
$checkStmt->execute([$userId]);
|
||||||
|
if ($checkStmt->fetch()) {
|
||||||
|
json_error('لقد استخدمت رمز إحالة مسبقاً', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the referral record
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$referralId = \App\Core\Database::generateUuid();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO referrals (id, referrer_id, referred_id, referral_code_id, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'registered', NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([$referralId, $referralCode['user_id'], $userId, $referralCode['id']]);
|
||||||
|
|
||||||
|
// 3. Notify the referrer
|
||||||
|
try {
|
||||||
|
$notifStmt = $db->prepare("
|
||||||
|
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
|
||||||
|
VALUES (UUID(), ?, ?, 'referral', '🎉 إحالة جديدة!', 'شخص جديد انضم باستخدام رمز إحالتك', ?, NOW())
|
||||||
|
");
|
||||||
|
$notifStmt->execute([
|
||||||
|
$referralCode['tenant_id'],
|
||||||
|
$referralCode['user_id'],
|
||||||
|
json_encode(['referral_id' => $referralId, 'code' => $code])
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail the whole operation if notification fails
|
||||||
|
error_log("[referral/apply] Notification failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'referral_id' => $referralId,
|
||||||
|
'referrer_code' => $code,
|
||||||
|
'status' => 'registered',
|
||||||
|
], 'تم تطبيق رمز الإحالة بنجاح! 🎉');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
safe_error($e, 'referral/apply', 'حدث خطأ في تطبيق رمز الإحالة.');
|
||||||
|
}
|
||||||
@@ -93,5 +93,5 @@ try {
|
|||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("Referral error: " . $e->getMessage() . " | Trace: " . $e->getTraceAsString());
|
error_log("Referral error: " . $e->getMessage() . " | Trace: " . $e->getTraceAsString());
|
||||||
json_error('حدث خطأ في نظام الإحالة: ' . $e->getMessage(), 500);
|
safe_error($e, 'referral/my_code', 'حدث خطأ في نظام الإحالة.');
|
||||||
}
|
}
|
||||||
|
|||||||
142
app/modules_app/reports/company_health.php
Normal file
142
app/modules_app/reports/company_health.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AI Company Health Report
|
||||||
|
* GET /v1/reports/company-health?company_id=xxx
|
||||||
|
*
|
||||||
|
* Generates an AI-powered financial health analysis using invoice data.
|
||||||
|
* Returns insights, warnings, and recommendations in Arabic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$tenantId = $decoded['tenant_id'];
|
||||||
|
$role = $decoded['role'];
|
||||||
|
$companyId = $_GET['company_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$companyId) {
|
||||||
|
json_error('معرّف الشركة مطلوب', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
$accessQuery = ($role === 'super_admin')
|
||||||
|
? "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND deleted_at IS NULL"
|
||||||
|
: "SELECT id, name, tax_identification_number FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL";
|
||||||
|
|
||||||
|
$accessParams = ($role === 'super_admin') ? [$companyId] : [$companyId, $tenantId];
|
||||||
|
$stmt = $db->prepare($accessQuery);
|
||||||
|
$stmt->execute($accessParams);
|
||||||
|
$company = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$company) {
|
||||||
|
json_error('الشركة غير موجودة أو ليس لديك صلاحية', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyName = Encryption::decrypt($company['name']) ?: $company['name'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Gather last 3 months of data
|
||||||
|
$months = [];
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$m = date('m', strtotime("-{$i} months"));
|
||||||
|
$y = date('Y', strtotime("-{$i} months"));
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_invoices,
|
||||||
|
COALESCE(SUM(grand_total), 0) as revenue,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax,
|
||||||
|
COALESCE(SUM(discount_total), 0) as discounts,
|
||||||
|
COALESCE(AVG(grand_total), 0) as avg_invoice,
|
||||||
|
SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count,
|
||||||
|
SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count
|
||||||
|
FROM invoices
|
||||||
|
WHERE company_id = ? AND MONTH(created_at) = ? AND YEAR(created_at) = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$companyId, $m, $y]);
|
||||||
|
$data = $stmt->fetch();
|
||||||
|
$data['month'] = (int)$m;
|
||||||
|
$data['year'] = (int)$y;
|
||||||
|
$months[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pending invoices count
|
||||||
|
$pendingStmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE company_id = ? AND status = 'extracted'");
|
||||||
|
$pendingStmt->execute([$companyId]);
|
||||||
|
$pendingCount = (int)$pendingStmt->fetchColumn();
|
||||||
|
|
||||||
|
// 3. Build AI prompt
|
||||||
|
$dataJson = json_encode([
|
||||||
|
'company_name' => $companyName,
|
||||||
|
'tin' => $company['tax_identification_number'],
|
||||||
|
'monthly_data' => $months,
|
||||||
|
'pending_invoices' => $pendingCount,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
أنت محلل مالي خبير. حلل البيانات التالية لشركة وأعطِ تقريراً مختصراً بالعربية.
|
||||||
|
|
||||||
|
البيانات:
|
||||||
|
{$dataJson}
|
||||||
|
|
||||||
|
أعد الرد بصيغة JSON فقط بدون أي نص إضافي:
|
||||||
|
{
|
||||||
|
"health_score": (رقم من 1 إلى 10),
|
||||||
|
"health_label": ("ممتاز" أو "جيد" أو "متوسط" أو "يحتاج انتباه"),
|
||||||
|
"summary": "ملخص من سطرين عن الحالة المالية",
|
||||||
|
"insights": ["ملاحظة 1", "ملاحظة 2", "ملاحظة 3"],
|
||||||
|
"warnings": ["تحذير إن وجد"],
|
||||||
|
"recommendations": ["توصية 1", "توصية 2"]
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$aiResponse = AI::ask($prompt, $tenantId);
|
||||||
|
|
||||||
|
// Parse AI response
|
||||||
|
$report = null;
|
||||||
|
if ($aiResponse) {
|
||||||
|
// Extract JSON from response
|
||||||
|
$cleaned = preg_replace('/```json?\s*|```/', '', $aiResponse);
|
||||||
|
$report = json_decode(trim($cleaned), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if AI fails
|
||||||
|
if (!$report) {
|
||||||
|
$currentMonth = $months[0] ?? [];
|
||||||
|
$prevMonth = $months[1] ?? [];
|
||||||
|
$score = 5;
|
||||||
|
|
||||||
|
if (($currentMonth['total_invoices'] ?? 0) > 0) $score += 2;
|
||||||
|
if (($currentMonth['submitted_count'] ?? 0) > 0) $score += 1;
|
||||||
|
if ($pendingCount === 0) $score += 1;
|
||||||
|
if (($currentMonth['revenue'] ?? 0) > ($prevMonth['revenue'] ?? 0)) $score += 1;
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'health_score' => min(10, $score),
|
||||||
|
'health_label' => $score >= 8 ? 'ممتاز' : ($score >= 6 ? 'جيد' : 'متوسط'),
|
||||||
|
'summary' => 'تقرير مبني على البيانات المتوفرة بدون تحليل AI.',
|
||||||
|
'insights' => ['عدد الفواتير: ' . ($currentMonth['total_invoices'] ?? 0)],
|
||||||
|
'warnings' => $pendingCount > 0 ? ["يوجد {$pendingCount} فاتورة بانتظار المراجعة"] : [],
|
||||||
|
'recommendations' => ['تأكد من إرسال جميع الفواتير المعتمدة لجوفوترا'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'company_name' => $companyName,
|
||||||
|
'report' => $report,
|
||||||
|
'data' => [
|
||||||
|
'monthly_summary' => $months,
|
||||||
|
'pending_count' => $pendingCount,
|
||||||
|
],
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
], 'تقرير صحة الشركة');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'reports/company-health', 'حدث خطأ في إنشاء التقرير.');
|
||||||
|
}
|
||||||
188
app/modules_app/sms/receive.php
Normal file
188
app/modules_app/sms/receive.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SMS Bank Integration — Receive & Auto-Match Payments
|
||||||
|
* POST /v1/sms/receive
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Android SMS Bot intercepts bank/wallet SMS
|
||||||
|
* 2. Sends it here: { "sender": "BANK_NAME", "message": "تم تحويل 45 دينار..." }
|
||||||
|
* 3. We save it in raw_sms_log with status "pending"
|
||||||
|
* 4. We immediately try to match it against pending payment requests
|
||||||
|
* 5. If matched → confirm payment → update subscription → notify user
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
|
||||||
|
// Auth: Verify webhook secret (shared between Android bot and server)
|
||||||
|
$webhookSecret = env('SMS_WEBHOOK_SECRET', '');
|
||||||
|
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? $_SERVER['HTTP_X_SMS_SECRET'] ?? '';
|
||||||
|
|
||||||
|
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json_data = file_get_contents('php://input');
|
||||||
|
$data = json_decode($json_data, true);
|
||||||
|
|
||||||
|
if (!$data || empty($data['sender']) || empty($data['message'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'بيانات غير مكتملة. يجب إرسال sender و message.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = trim($data['sender']);
|
||||||
|
$message = trim($data['message']);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Save raw SMS log
|
||||||
|
$smsId = \App\Core\Database::generateUuid();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO raw_sms_log (id, sender, message_body, status, received_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([$smsId, $sender, $message]);
|
||||||
|
|
||||||
|
// 2. Try to auto-match with pending payments
|
||||||
|
$matchResult = matchPayment($db, $smsId, $sender, $message);
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'SMS received and processed.',
|
||||||
|
'matched' => $matchResult['matched'],
|
||||||
|
'details' => $matchResult['details'] ?? null,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("[sms/receive] Error: " . $e->getMessage());
|
||||||
|
http_response_code(200); // Return 200 so bot doesn't retry
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'خطأ داخلي في المعالجة.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to match the incoming SMS with a pending payment request.
|
||||||
|
*
|
||||||
|
* Matching logic:
|
||||||
|
* 1. Extract reference number from SMS (formats: MSQ-XXXX, REF-XXXX, or plain digits)
|
||||||
|
* 2. Extract amount from SMS
|
||||||
|
* 3. Find pending payment request matching reference OR amount
|
||||||
|
* 4. If matched → confirm payment → activate/extend subscription
|
||||||
|
*/
|
||||||
|
function matchPayment(\PDO $db, string $smsId, string $sender, string $message): array
|
||||||
|
{
|
||||||
|
// Extract reference number (MSQ-XXXX pattern or any 6+ digit number)
|
||||||
|
$reference = null;
|
||||||
|
if (preg_match('/MSQ-([A-Z0-9]{4,10})/i', $message, $m)) {
|
||||||
|
$reference = 'MSQ-' . strtoupper($m[1]);
|
||||||
|
} elseif (preg_match('/REF[:\s-]*([A-Z0-9]{4,12})/i', $message, $m)) {
|
||||||
|
$reference = $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract amount (Arabic or English digits)
|
||||||
|
$amount = null;
|
||||||
|
$msgNormalized = strtr($message, ['٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']);
|
||||||
|
if (preg_match('/(\d+[\.,]?\d{0,3})\s*(دينار|JOD|JD)/iu', $msgNormalized, $m)) {
|
||||||
|
$amount = (float)str_replace(',', '.', $m[1]);
|
||||||
|
} elseif (preg_match('/(\d+[\.,]\d{2})/', $msgNormalized, $m)) {
|
||||||
|
$amount = (float)str_replace(',', '.', $m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$reference && !$amount) {
|
||||||
|
// Can't match — mark SMS as unmatched
|
||||||
|
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', processed_at = NOW() WHERE id = ?")->execute([$smsId]);
|
||||||
|
return ['matched' => false, 'details' => 'لم يتم العثور على مرجع أو مبلغ في الرسالة'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for pending payment request
|
||||||
|
$where = "pr.status = 'pending'";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($reference) {
|
||||||
|
$where .= " AND pr.reference_number = ?";
|
||||||
|
$params[] = $reference;
|
||||||
|
}
|
||||||
|
if ($amount) {
|
||||||
|
$where .= " AND pr.amount = ?";
|
||||||
|
$params[] = $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT pr.*, t.name as tenant_name
|
||||||
|
FROM payment_requests pr
|
||||||
|
LEFT JOIN tenants t ON pr.tenant_id = t.id
|
||||||
|
WHERE {$where}
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$payment = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$payment) {
|
||||||
|
$db->prepare("UPDATE raw_sms_log SET status = 'unmatched', extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?")
|
||||||
|
->execute([$reference, $amount, $smsId]);
|
||||||
|
return ['matched' => false, 'details' => "مرجع: {$reference}, مبلغ: {$amount} — لم يتطابق مع أي طلب دفع"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MATCH FOUND — Process payment
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Update payment request → confirmed
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE payment_requests SET status = 'confirmed', sms_log_id = ?, confirmed_at = NOW() WHERE id = ?
|
||||||
|
")->execute([$smsId, $payment['id']]);
|
||||||
|
|
||||||
|
// 2. Update SMS log → matched
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE raw_sms_log SET status = 'matched', payment_request_id = ?, extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?
|
||||||
|
")->execute([$payment['id'], $reference, $amount, $smsId]);
|
||||||
|
|
||||||
|
// 3. Activate/extend subscription
|
||||||
|
$planMonths = (int)($payment['plan_months'] ?? 1);
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET is_active = 1,
|
||||||
|
started_at = COALESCE(started_at, NOW()),
|
||||||
|
expires_at = DATE_ADD(COALESCE(expires_at, NOW()), INTERVAL ? MONTH),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
")->execute([$planMonths, $payment['tenant_id']]);
|
||||||
|
|
||||||
|
// 4. Notify user
|
||||||
|
\App\Services\SmartNotifications::send(
|
||||||
|
$payment['tenant_id'],
|
||||||
|
$payment['user_id'] ?? '',
|
||||||
|
'payment_confirmed',
|
||||||
|
'✅ تم تأكيد الدفع!',
|
||||||
|
"تم تأكيد دفعة بقيمة {$payment['amount']} دينار. اشتراكك فعّال الآن.",
|
||||||
|
['payment_id' => $payment['id'], 'amount' => $payment['amount']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Audit log
|
||||||
|
AuditLogger::log('payment.auto_confirmed', 'payment', $payment['id'], null, [
|
||||||
|
'sms_id' => $smsId,
|
||||||
|
'sender' => $sender,
|
||||||
|
'reference' => $reference,
|
||||||
|
'amount' => $amount,
|
||||||
|
], ['user_id' => 'system', 'tenant_id' => $payment['tenant_id'], 'role' => 'system']);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'matched' => true,
|
||||||
|
'details' => "تم مطابقة الدفعة: {$payment['amount']} دينار — الاشتراك مُفعّل",
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
error_log("[sms/match] Failed: " . $e->getMessage());
|
||||||
|
return ['matched' => false, 'details' => 'خطأ أثناء تأكيد الدفعة'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,5 +91,5 @@ try {
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
error_log("Subscription Assign Error: " . $e->getMessage());
|
error_log("Subscription Assign Error: " . $e->getMessage());
|
||||||
json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500);
|
safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,6 @@ try {
|
|||||||
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
|
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,5 +42,5 @@ try {
|
|||||||
json_success($tenants);
|
json_success($tenants);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Tenants List: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,5 @@ try {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('Stats Error: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/stats');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,5 @@ try {
|
|||||||
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
|
json_success(null, 'تم تحديث بيانات المكتب بنجاح');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500);
|
safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ $errors = Validator::validate($data, [
|
|||||||
'name' => 'required',
|
'name' => 'required',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
'phone' => 'required',
|
'phone' => 'required',
|
||||||
'password' => 'required',
|
'password' => 'required|strong_password',
|
||||||
'role' => 'required'
|
'role' => 'required'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Users List Endpoint (Role-Based & Tenant-Aware)
|
* Users List Endpoint (Role-Based, Tenant-Aware, Paginated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
@@ -14,37 +14,58 @@ $db = Database::getInstance();
|
|||||||
$role = $decoded['role'];
|
$role = $decoded['role'];
|
||||||
$tenantId = $decoded['tenant_id'] ?? null;
|
$tenantId = $decoded['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
if ($role !== 'super_admin' && $role !== 'admin') {
|
||||||
|
json_error('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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') {
|
if ($role === 'super_admin') {
|
||||||
// Super Admin sees ALL users from ALL tenants
|
$where = '1=1';
|
||||||
$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]);
|
|
||||||
} else {
|
} else {
|
||||||
// Other roles shouldn't see user list
|
$where = 'u.tenant_id = ?';
|
||||||
json_error('Unauthorized', 403);
|
$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();
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
// 3. Decrypt data and format
|
// 5. Decrypt data
|
||||||
$dec = function($val) {
|
$dec = function($val) {
|
||||||
if (empty($val)) return '';
|
if (empty($val)) return '';
|
||||||
$result = \App\Core\Encryption::decrypt((string)$val);
|
$result = Encryption::decrypt((string)$val);
|
||||||
return ($result !== false && $result !== null) ? $result : (string)$val;
|
return ($result !== false && $result !== null) ? $result : (string)$val;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,18 +75,13 @@ try {
|
|||||||
if (!empty($user['phone'])) {
|
if (!empty($user['phone'])) {
|
||||||
$user['phone'] = $dec($user['phone']);
|
$user['phone'] = $dec($user['phone']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($user['tenant_name'])) {
|
if (!empty($user['tenant_name'])) {
|
||||||
$user['tenant_name'] = $dec($user['tenant_name']);
|
$user['tenant_name'] = $dec($user['tenant_name']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($users)) {
|
json_paginated($users, $total, $pagination);
|
||||||
error_log("USERS LIST: No users found for role: $role, tenant_id: $tenantId");
|
|
||||||
}
|
|
||||||
|
|
||||||
json_success($users);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
json_error('SQL Error in Users List: ' . $e->getMessage(), 500);
|
safe_error($e, 'users/index');
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/modules_app/whatsapp/link_code.php
Normal file
35
app/modules_app/whatsapp/link_code.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Generate WhatsApp Link Code
|
||||||
|
* GET /v1/whatsapp/link-code
|
||||||
|
*
|
||||||
|
* Generates a one-time code that the user sends to the WhatsApp bot
|
||||||
|
* to link their phone number with their Musadaq account.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$userId = $decoded['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate a short, easy-to-type code
|
||||||
|
$code = strtoupper(substr(md5($userId . time() . random_int(1000, 9999)), 0, 6));
|
||||||
|
|
||||||
|
// Save the code (expires in 10 minutes)
|
||||||
|
$stmt = $db->prepare("UPDATE users SET whatsapp_link_code = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$code, $userId]);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'code' => $code,
|
||||||
|
'expires_in' => 600, // 10 minutes
|
||||||
|
'instruction' => "أرسل هذه الرسالة للرقم التالي على واتساب:\n\nربط {$code}",
|
||||||
|
'bot_number' => env('WHATSAPP_BOT_NUMBER', '+962XXXXXXXXX'),
|
||||||
|
], 'تم إنشاء كود الربط');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
safe_error($e, 'whatsapp/link-code', 'حدث خطأ في إنشاء كود الربط.');
|
||||||
|
}
|
||||||
259
app/modules_app/whatsapp/webhook.php
Normal file
259
app/modules_app/whatsapp/webhook.php
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WhatsApp Bot Webhook
|
||||||
|
* POST /v1/whatsapp/webhook
|
||||||
|
*
|
||||||
|
* Receives incoming WhatsApp messages (text + images) via the proxy bot.
|
||||||
|
* Flow: User sends invoice image → Bot processes via AI → Returns extracted data.
|
||||||
|
*
|
||||||
|
* Supported commands:
|
||||||
|
* - Image/Document: Extracts invoice data via AI
|
||||||
|
* - "ربط [CODE]": Links WhatsApp number to Musadaq account
|
||||||
|
* - "حالتي" or "status": Returns account summary
|
||||||
|
* - "مساعدة" or "help": Returns command list
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\AI;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
use App\Core\AuditLogger;
|
||||||
|
|
||||||
|
// No auth middleware — this is a webhook from the bot proxy
|
||||||
|
// Verify webhook secret instead
|
||||||
|
$webhookSecret = env('WHATSAPP_WEBHOOK_SECRET', '');
|
||||||
|
$incomingSecret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
|
||||||
|
|
||||||
|
if (!empty($webhookSecret) && !hash_equals($webhookSecret, $incomingSecret)) {
|
||||||
|
json_error('Unauthorized webhook', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$body) {
|
||||||
|
json_error('Invalid payload', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $body['from'] ?? ''; // Phone number (962XXXXXXXXX)
|
||||||
|
$text = $body['message']['text'] ?? '';
|
||||||
|
$imageUrl = $body['message']['image_url'] ?? null;
|
||||||
|
$imageData = $body['message']['image_base64'] ?? null;
|
||||||
|
$mimeType = $body['message']['mime_type'] ?? 'image/jpeg';
|
||||||
|
|
||||||
|
if (empty($from)) {
|
||||||
|
json_error('Missing sender number', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$wa = new \App\Services\WhatsAppProxyService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Look up linked account by phone hash
|
||||||
|
$phoneClean = preg_replace('/[^0-9+]/', '', $from);
|
||||||
|
$phoneHash = hash('sha256', $phoneClean);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT u.id, u.tenant_id, u.name, u.role FROM users u WHERE u.phone_hash = ? AND u.is_active = 1 LIMIT 1");
|
||||||
|
$stmt->execute([$phoneHash]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
// 2. Handle commands
|
||||||
|
$textLower = mb_strtolower(trim($text));
|
||||||
|
|
||||||
|
// === LINK COMMAND ===
|
||||||
|
if (str_starts_with($textLower, 'ربط ') || str_starts_with($textLower, 'link ')) {
|
||||||
|
$code = trim(str_replace(['ربط', 'link'], '', $text));
|
||||||
|
handleLinkCommand($db, $wa, $from, $phoneHash, $code);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HELP COMMAND ===
|
||||||
|
if (in_array($textLower, ['مساعدة', 'help', '؟', '?'])) {
|
||||||
|
$wa->sendMessage($from, "🤖 *أوامر مُصادَق:*\n\n"
|
||||||
|
. "📸 أرسل صورة فاتورة → نستخرج البيانات بالـ AI\n"
|
||||||
|
. "🔗 ربط [الكود] → لربط رقمك بحسابك\n"
|
||||||
|
. "📊 حالتي → ملخص حسابك\n"
|
||||||
|
. "❓ مساعدة → هذه الرسالة\n\n"
|
||||||
|
. "للتسجيل: musadaq.intaleqapp.com");
|
||||||
|
json_success(null, 'Help sent');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ACCOUNT NOT LINKED ===
|
||||||
|
if (!$user) {
|
||||||
|
$wa->sendMessage($from, "👋 مرحباً!\n\n"
|
||||||
|
. "رقمك غير مربوط بحساب مُصادَق.\n"
|
||||||
|
. "لربط حسابك، أرسل: *ربط [الكود]*\n\n"
|
||||||
|
. "للحصول على الكود، افتح تطبيق مُصادَق → الإعدادات → ربط واتساب.\n\n"
|
||||||
|
. "أو سجّل حساب جديد: musadaq.intaleqapp.com");
|
||||||
|
json_success(null, 'Unlinked user guided');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userName = Encryption::decrypt($user['name']) ?: 'المستخدم';
|
||||||
|
|
||||||
|
// === STATUS COMMAND ===
|
||||||
|
if (in_array($textLower, ['حالتي', 'status', 'حالة'])) {
|
||||||
|
handleStatusCommand($db, $wa, $from, $user, $userName);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === IMAGE/INVOICE PROCESSING ===
|
||||||
|
if ($imageData || $imageUrl) {
|
||||||
|
handleInvoiceImage($db, $wa, $from, $user, $userName, $imageData, $imageUrl, $mimeType);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEFAULT: Unknown text ===
|
||||||
|
$wa->sendMessage($from, "مرحباً {$userName} 👋\n\n"
|
||||||
|
. "لم أفهم طلبك. يمكنك:\n"
|
||||||
|
. "📸 إرسال صورة فاتورة لاستخراج البيانات\n"
|
||||||
|
. "📊 كتابة *حالتي* لملخص حسابك\n"
|
||||||
|
. "❓ كتابة *مساعدة* لقائمة الأوامر");
|
||||||
|
|
||||||
|
json_success(null, 'Default response sent');
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[whatsapp/webhook] Error: " . $e->getMessage());
|
||||||
|
try {
|
||||||
|
$wa->sendMessage($from, "⚠️ حدث خطأ أثناء المعالجة. يرجى المحاولة مرة أخرى.");
|
||||||
|
} catch (\Throwable $ignore) {}
|
||||||
|
json_success(null, 'Error handled'); // Return 200 so the bot doesn't retry
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// HANDLER FUNCTIONS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
function handleLinkCommand($db, $wa, string $from, string $phoneHash, string $code): void
|
||||||
|
{
|
||||||
|
if (empty($code)) {
|
||||||
|
$wa->sendMessage($from, "❌ يرجى إرسال الكود. مثال: *ربط ABC123*");
|
||||||
|
json_success(null, 'Empty code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user by link code
|
||||||
|
$stmt = $db->prepare("SELECT id, tenant_id FROM users WHERE whatsapp_link_code = ? AND is_active = 1 LIMIT 1");
|
||||||
|
$stmt->execute([strtoupper(trim($code))]);
|
||||||
|
$targetUser = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$targetUser) {
|
||||||
|
$wa->sendMessage($from, "❌ الكود غير صحيح. تأكد من الكود في تطبيق مُصادَق → الإعدادات → ربط واتساب.");
|
||||||
|
json_success(null, 'Invalid code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's phone hash
|
||||||
|
$updateStmt = $db->prepare("UPDATE users SET phone_hash = ?, whatsapp_linked = 1, whatsapp_link_code = NULL WHERE id = ?");
|
||||||
|
$updateStmt->execute([$phoneHash, $targetUser['id']]);
|
||||||
|
|
||||||
|
$wa->sendMessage($from, "✅ تم ربط رقمك بحسابك بنجاح! 🎉\n\n"
|
||||||
|
. "الآن يمكنك إرسال صور الفواتير مباشرة هنا وسنستخرج البيانات تلقائياً.");
|
||||||
|
|
||||||
|
json_success(null, 'Account linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusCommand($db, $wa, string $from, array $user, string $userName): void
|
||||||
|
{
|
||||||
|
$tenantId = $user['tenant_id'];
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
$invoiceStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) as pending FROM invoices WHERE tenant_id = ?");
|
||||||
|
$invoiceStmt->execute([$tenantId]);
|
||||||
|
$stats = $invoiceStmt->fetch();
|
||||||
|
|
||||||
|
$subStmt = $db->prepare("SELECT plan_slug, invoices_used_this_month, max_invoices_per_month FROM subscriptions WHERE tenant_id = ?");
|
||||||
|
$subStmt->execute([$tenantId]);
|
||||||
|
$sub = $subStmt->fetch();
|
||||||
|
|
||||||
|
$plan = $sub['plan_slug'] ?? 'free';
|
||||||
|
$used = $sub['invoices_used_this_month'] ?? 0;
|
||||||
|
$max = $sub['max_invoices_per_month'] ?? 15;
|
||||||
|
|
||||||
|
$msg = "📊 *ملخص حسابك، {$userName}:*\n\n"
|
||||||
|
. "📋 إجمالي الفواتير: {$stats['total']}\n"
|
||||||
|
. "⏳ بانتظار المراجعة: {$stats['pending']}\n"
|
||||||
|
. "📦 الباقة: {$plan}\n"
|
||||||
|
. "🔢 الاستخدام: {$used}/{$max} فاتورة هذا الشهر\n\n"
|
||||||
|
. "🌐 لوحة التحكم: musadaq.intaleqapp.com";
|
||||||
|
|
||||||
|
$wa->sendMessage($from, $msg);
|
||||||
|
json_success(null, 'Status sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInvoiceImage($db, $wa, string $from, array $user, string $userName, ?string $imageData, ?string $imageUrl, string $mimeType): void
|
||||||
|
{
|
||||||
|
$wa->sendMessage($from, "📸 استلمت الصورة! جارٍ استخراج البيانات بالذكاء الاصطناعي... ⏳");
|
||||||
|
|
||||||
|
// Get image data
|
||||||
|
if (!$imageData && $imageUrl) {
|
||||||
|
$imageContent = @file_get_contents($imageUrl);
|
||||||
|
if (!$imageContent) {
|
||||||
|
$wa->sendMessage($from, "❌ فشل تحميل الصورة. يرجى إرسالها مرة أخرى.");
|
||||||
|
json_success(null, 'Image download failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$imageData = base64_encode($imageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$imageData) {
|
||||||
|
$wa->sendMessage($from, "❌ لم أتمكن من قراءة الصورة.");
|
||||||
|
json_success(null, 'No image data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run AI extraction
|
||||||
|
$extracted = AI::extractInvoiceData($imageData, $mimeType);
|
||||||
|
|
||||||
|
if (!$extracted) {
|
||||||
|
$wa->sendMessage($from, "⚠️ لم أتمكن من استخراج البيانات. تأكد أن الصورة واضحة وتحتوي على فاتورة.");
|
||||||
|
json_success(null, 'AI extraction failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
$supplierName = $extracted['supplier']['name'] ?? 'غير محدد';
|
||||||
|
$invoiceNum = $extracted['invoice_number'] ?? '-';
|
||||||
|
$invoiceDate = $extracted['invoice_date'] ?? '-';
|
||||||
|
$subtotal = number_format((float)($extracted['subtotal'] ?? 0), 2);
|
||||||
|
$tax = number_format((float)($extracted['tax_amount'] ?? 0), 2);
|
||||||
|
$total = number_format((float)($extracted['grand_total'] ?? 0), 2);
|
||||||
|
$linesCount = count($extracted['lines'] ?? []);
|
||||||
|
|
||||||
|
$msg = "✅ *تم استخراج بيانات الفاتورة:*\n\n"
|
||||||
|
. "🏢 المورد: {$supplierName}\n"
|
||||||
|
. "🔢 رقم الفاتورة: {$invoiceNum}\n"
|
||||||
|
. "📅 التاريخ: {$invoiceDate}\n"
|
||||||
|
. "📦 البنود: {$linesCount}\n"
|
||||||
|
. "───────────────\n"
|
||||||
|
. "💰 المبلغ قبل الضريبة: {$subtotal} دينار\n"
|
||||||
|
. "🏛️ الضريبة: {$tax} دينار\n"
|
||||||
|
. "📊 *الإجمالي: {$total} دينار*\n\n";
|
||||||
|
|
||||||
|
// Add warnings if any
|
||||||
|
if (!empty($extracted['validation_warnings'])) {
|
||||||
|
$msg .= "⚠️ *تحذيرات:*\n";
|
||||||
|
foreach ($extracted['validation_warnings'] as $w) {
|
||||||
|
$msg .= "• {$w}\n";
|
||||||
|
}
|
||||||
|
$msg .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg .= "💡 لحفظ هذه الفاتورة رسمياً، ارفعها من تطبيق مُصادَق.";
|
||||||
|
|
||||||
|
$wa->sendMessage($from, $msg);
|
||||||
|
|
||||||
|
// Log the interaction
|
||||||
|
try {
|
||||||
|
AuditLogger::log('whatsapp.invoice_extracted', 'whatsapp', null, null, [
|
||||||
|
'from' => substr($from, 0, 6) . '****',
|
||||||
|
'invoice_number' => $invoiceNum,
|
||||||
|
'total' => $total,
|
||||||
|
], ['user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
|
||||||
|
json_success(null, 'Invoice extracted via WhatsApp');
|
||||||
|
}
|
||||||
31
database/migrations/004_gamification_whatsapp.sql
Normal file
31
database/migrations/004_gamification_whatsapp.sql
Normal 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;
|
||||||
14
database/migrations/005_chatbot.sql
Normal file
14
database/migrations/005_chatbot.sql
Normal 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;
|
||||||
21
database/migrations/006_sms_bank.sql
Normal file
21
database/migrations/006_sms_bank.sql
Normal 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;
|
||||||
20
database/migrations/007_marketplace.sql
Normal file
20
database/migrations/007_marketplace.sql
Normal 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;
|
||||||
@@ -134,6 +134,8 @@ PODS:
|
|||||||
- sqflite_darwin (0.0.4):
|
- sqflite_darwin (0.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- camerawesome (from `.symlinks/plugins/camerawesome/ios`)
|
- camerawesome (from `.symlinks/plugins/camerawesome/ios`)
|
||||||
@@ -156,6 +158,7 @@ DEPENDENCIES:
|
|||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
|
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -217,6 +220,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/speech_to_text/darwin"
|
:path: ".symlinks/plugins/speech_to_text/darwin"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
camerawesome: a961fa32dafc00d2f093d824311c84f849586b58
|
camerawesome: a961fa32dafc00d2f093d824311c84f849586b58
|
||||||
@@ -255,6 +260,7 @@ SPEC CHECKSUMS:
|
|||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
|
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
|
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||||
|
|
||||||
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079
|
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,39 @@ $routes = [
|
|||||||
'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'],
|
'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'],
|
||||||
'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'],
|
'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'],
|
||||||
'v1/voice/parse-intent-grok' => ['POST', 'voice/grok_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])) {
|
if (isset($routes[$route])) {
|
||||||
@@ -97,11 +130,12 @@ if (isset($routes[$route])) {
|
|||||||
if (file_exists($file)) {
|
if (file_exists($file)) {
|
||||||
require_once $file;
|
require_once $file;
|
||||||
} else {
|
} else {
|
||||||
json_error("Endpoint file missing: {$route}", 500);
|
error_log("Router: Missing module file for route '{$route}': {$moduleFile}");
|
||||||
|
json_error('خدمة غير متوفرة حالياً', 500);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (str_starts_with($route, 'v1/')) {
|
if (str_starts_with($route, 'v1/')) {
|
||||||
json_error("Not Found: {$route}", 404);
|
json_error('المسار المطلوب غير موجود', 404);
|
||||||
} else {
|
} else {
|
||||||
include __DIR__ . '/shell.php';
|
include __DIR__ . '/shell.php';
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
321
public/landing.php
Normal file
321
public/landing.php
Normal 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>
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Quick Encryption Tool
|
* Quick Encryption Tool (Development Only — Protected)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||||||
|
|
||||||
use App\Core\Encryption;
|
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'] ?? '';
|
$input = $_GET['text'] ?? '';
|
||||||
$encrypted = $input ? Encryption::encrypt($input) : '';
|
$encrypted = $input ? Encryption::encrypt($input) : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user