Update: 2026-05-05 00:01:17

This commit is contained in:
Hamza-Ayed
2026-05-05 00:01:17 +03:00
parent 5f7018390a
commit ac12106770
14 changed files with 969 additions and 8 deletions

116
app/config/plans.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
/**
* Subscription Plans Configuration (Fallback)
*
* This is used as a fallback when the database subscription_plans
* table is not available. The database is the source of truth.
*/
return [
'free' => [
'id' => 'free',
'name_ar' => 'مجانية',
'name_en' => 'Free',
'max_companies' => 1,
'max_invoices_month' => 15,
'max_users' => 1,
'price_jod' => 0.00,
'ai_features' => false,
'jofotara_enabled' => false,
'badge_color' => 'gray',
'description_ar' => 'للتجربة الأولية — شركة واحدة و15 فاتورة شهرياً',
'features' => [
'استخراج بيانات الفواتير يدوياً',
'شركة واحدة فقط',
'15 فاتورة شهرياً',
'مستخدم واحد',
],
],
'basic' => [
'id' => 'basic',
'name_ar' => 'أساسية',
'name_en' => 'Basic',
'max_companies' => 3,
'max_invoices_month' => 100,
'max_users' => 3,
'price_jod' => 15.00,
'ai_features' => true,
'jofotara_enabled' => false,
'badge_color' => 'blue',
'description_ar' => 'للمحاسبين المستقلين — ذكاء اصطناعي + 3 شركات',
'features' => [
'استخراج ذكي بالـ AI',
'حتى 3 شركات',
'100 فاتورة شهرياً',
'3 مستخدمين',
'تقارير شهرية',
],
],
'office' => [
'id' => 'office',
'name_ar' => 'مكتبية',
'name_en' => 'Office',
'max_companies' => 10,
'max_invoices_month' => 500,
'max_users' => 10,
'price_jod' => 45.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'teal',
'is_popular' => true,
'description_ar' => 'للمكاتب المحاسبية — ربط مباشر بجوفوترة',
'features' => [
'كل ميزات الأساسية',
'ربط مباشر بنظام JoFotara',
'حتى 10 شركات',
'500 فاتورة شهرياً',
'10 مستخدمين',
'تقارير متقدمة + تصدير',
'دعم فني بالأولوية',
],
],
'pro' => [
'id' => 'pro',
'name_ar' => 'احترافية',
'name_en' => 'Pro',
'max_companies' => 25,
'max_invoices_month' => 2000,
'max_users' => 25,
'price_jod' => 99.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'navy',
'description_ar' => 'للمكاتب الكبيرة — حجم عمل ضخم بلا حدود عملية',
'features' => [
'كل ميزات المكتبية',
'حتى 25 شركة',
'2000 فاتورة شهرياً',
'25 مستخدم',
'API كامل لتطبيق الهاتف',
'تدقيق ذكي بالـ AI (Pre-Audit)',
'مدير حساب مخصص',
],
],
'enterprise' => [
'id' => 'enterprise',
'name_ar' => 'مؤسسية',
'name_en' => 'Enterprise',
'max_companies' => 999,
'max_invoices_month' => 99999,
'max_users' => 999,
'price_jod' => 249.00,
'ai_features' => true,
'jofotara_enabled' => true,
'badge_color' => 'gold',
'description_ar' => 'للمؤسسات — بلا حدود مع دعم مخصص',
'features' => [
'كل ميزات الاحترافية',
'شركات وفواتير بلا حدود عملية',
'مستخدمين بلا حدود',
'SLA مضمون 99.9%',
'ربط API مخصص',
'تدريب فريق المحاسبة',
'نسخ احتياطي مخصص',
],
],
];

View File

@@ -0,0 +1,270 @@
<?php
/**
* Quota Enforcement Middleware
*
* Checks tenant subscription limits before allowing resource creation.
* Automatically resets monthly counters when the billing period rolls over.
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Database;
final class QuotaMiddleware
{
/**
* Check if the tenant can upload more invoices this month.
* Automatically resets the counter if the billing period has ended.
*
* @return array The current subscription data (for UI display)
*/
public static function checkInvoiceQuota(string $tenantId): array
{
$db = Database::getInstance();
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
}
// Check subscription status
if ($sub['status'] === 'cancelled') {
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
}
if ($sub['status'] === 'past_due') {
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
}
// Auto-reset monthly counter if billing period has ended
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
$newStart = date('Y-m-d H:i:s');
$newEnd = date('Y-m-d H:i:s', strtotime('+30 days'));
$resetStmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = 0,
current_period_start = ?,
current_period_end = ?,
updated_at = NOW()
WHERE tenant_id = ?
");
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
$sub['invoices_used_this_month'] = 0;
$sub['current_period_start'] = $newStart;
$sub['current_period_end'] = $newEnd;
error_log("QuotaMiddleware: Auto-reset monthly counter for tenant {$tenantId}");
}
// Check invoice quota
$used = (int)$sub['invoices_used_this_month'];
$limit = (int)$sub['max_invoices_per_month'];
if ($used >= $limit) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'period_end' => $sub['current_period_end'],
]);
}
return $sub;
}
/**
* Increment the monthly invoice counter after a successful upload.
*/
public static function incrementInvoiceUsage(string $tenantId): void
{
$db = Database::getInstance();
$stmt = $db->prepare("
UPDATE subscriptions
SET invoices_used_this_month = invoices_used_this_month + 1,
updated_at = NOW()
WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
}
/**
* Check if the tenant can add more companies.
*/
public static function checkCompanyQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active companies
$countStmt = $db->prepare("
SELECT COUNT(*) FROM companies
WHERE tenant_id = ? AND (deleted_at IS NULL)
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$limit = (int)$sub['max_companies'];
if ($currentCount >= $limit) {
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
'quota_type' => 'companies',
'used' => $currentCount,
'limit' => $limit,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Check if the tenant can add more users.
*/
public static function checkUserQuota(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
}
// Count current active users in this tenant
$countStmt = $db->prepare("
SELECT COUNT(*) FROM users
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
");
$countStmt->execute([$tenantId]);
$currentCount = (int)$countStmt->fetchColumn();
$maxUsers = (int)($sub['max_users'] ?? 999);
if ($currentCount >= $maxUsers) {
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
'quota_type' => 'users',
'used' => $currentCount,
'limit' => $maxUsers,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
]);
}
return $sub;
}
/**
* Get usage summary for a tenant (for dashboard display).
*/
public static function getUsageSummary(string $tenantId): array
{
$db = Database::getInstance();
// Get subscription
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
FROM subscriptions s
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
WHERE s.tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) {
return [
'has_subscription' => false,
'plan' => 'none',
];
}
// Count companies
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$compStmt->execute([$tenantId]);
$companiesUsed = (int)$compStmt->fetchColumn();
// Count users
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
$userStmt->execute([$tenantId]);
$usersUsed = (int)$userStmt->fetchColumn();
$invoicesUsed = (int)$sub['invoices_used_this_month'];
$invoicesLimit = (int)$sub['max_invoices_per_month'];
$companiesLimit = (int)$sub['max_companies'];
$usersLimit = (int)($sub['max_users'] ?? 999);
return [
'has_subscription' => true,
'plan_id' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
'plan_price' => (float)($sub['plan_price'] ?? 0),
'status' => $sub['status'],
'ai_features' => (bool)($sub['ai_features'] ?? false),
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
'invoices' => [
'used' => $invoicesUsed,
'limit' => $invoicesLimit,
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
],
'companies' => [
'used' => $companiesUsed,
'limit' => $companiesLimit,
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
],
'users' => [
'used' => $usersUsed,
'limit' => $usersLimit,
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
],
'period_start' => $sub['current_period_start'],
'period_end' => $sub['current_period_end'],
'trial_ends_at' => $sub['trial_ends_at'],
'days_remaining' => !empty($sub['current_period_end'])
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
: null,
];
}
}

View File

@@ -59,6 +59,10 @@ try {
$tenantId = $decoded['tenant_id'];
}
// --- QUOTA CHECK ---
\App\Middleware\QuotaMiddleware::checkCompanyQuota($tenantId);
// -------------------
$stmt->execute([
\App\Core\Database::generateUuid(),
$tenantId,

View File

@@ -8,6 +8,13 @@ use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
// --- QUOTA CHECK ---
\App\Middleware\QuotaMiddleware::checkInvoiceQuota($tenantId);
// -------------------
$db = Database::getInstance();
$allowedRoles = ['admin', 'accountant', 'employee'];
@@ -141,6 +148,11 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) {
}
$db->commit();
// --- INCREMENT QUOTA ---
\App\Middleware\QuotaMiddleware::incrementInvoiceUsage($tenantId);
// -----------------------
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
} catch (\Exception $e) {

View File

@@ -0,0 +1,95 @@
<?php
/**
* Assign/Upgrade Subscription Plan (Super Admin only)
* POST /api/v1/subscriptions/assign
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
// Only Super Admin can change plans manually via this API
if ($decoded['role'] !== 'super_admin') {
json_error('غير مصرح لك بتغيير الباقات. يرجى التواصل مع الدعم الفني.', 403);
}
$data = input();
$targetTenantId = $data['tenant_id'] ?? null;
$planId = $data['plan_id'] ?? null;
$durationDays = (int)($data['duration_days'] ?? 30);
if (!$targetTenantId || !$planId) {
json_error('معرف المكتب ومعرف الباقة مطلوبان.', 422);
}
$db = Database::getInstance();
try {
// 1. Validate Plan
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$planId]);
$plan = $stmt->fetch();
if (!$plan) {
json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422);
}
// 2. Update or Create Subscription
$db->beginTransaction();
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime("+{$durationDays} days"));
$stmt = $db->prepare("
INSERT INTO subscriptions (
tenant_id, plan_id, max_companies, max_invoices_per_month, max_users,
price_jod, status, current_period_start, current_period_end, updated_at
) VALUES (
:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()
)
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $targetTenantId,
'p_id' => $planId,
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
// 3. Log the change
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'plan_assigned', 'tenant', ?, ?)");
$logStmt->execute([
$targetTenantId,
$decoded['user_id'],
$targetTenantId,
json_encode(['plan_id' => $planId, 'assigned_by' => $decoded['user_id']])
]);
$db->commit();
json_success([
'tenant_id' => $targetTenantId,
'plan_id' => $planId,
'period_end' => $endDate
], 'تم تحديث باقة الاشتراك بنجاح');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Subscription Assign Error: " . $e->getMessage());
json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500);
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* Get Current Tenant Subscription & Usage
* GET /api/v1/subscriptions/current
*/
use App\Middleware\AuthMiddleware;
use App\Middleware\QuotaMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
try {
$usage = QuotaMiddleware::getUsageSummary($tenantId);
if (!$usage['has_subscription']) {
json_error('لم يتم العثور على اشتراك نشط لهذا المكتب.', 404);
}
json_success($usage, 'تفاصيل الاشتراك الحالي');
} catch (\Exception $e) {
error_log("Subscription Current Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب بيانات الاشتراك', 500);
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* List Available Subscription Plans
* GET /api/v1/subscriptions/plans
*/
use App\Core\Database;
// No auth required — public endpoint for pricing page
$db = Database::getInstance();
try {
$stmt = $db->query("
SELECT id, name_ar, name_en, max_companies, max_invoices_month, max_users,
price_jod, ai_features, jofotara_enabled, sort_order
FROM subscription_plans
WHERE is_active = 1
ORDER BY sort_order ASC
");
$plans = $stmt->fetchAll();
// Merge with config features (for richer display)
$configPlans = require APP_PATH . '/config/plans.php';
foreach ($plans as &$plan) {
$configPlan = $configPlans[$plan['id']] ?? null;
if ($configPlan) {
$plan['description_ar'] = $configPlan['description_ar'] ?? '';
$plan['features'] = $configPlan['features'] ?? [];
$plan['badge_color'] = $configPlan['badge_color'] ?? 'gray';
$plan['is_popular'] = $configPlan['is_popular'] ?? false;
}
// Cast numeric fields
$plan['max_companies'] = (int)$plan['max_companies'];
$plan['max_invoices_month'] = (int)$plan['max_invoices_month'];
$plan['max_users'] = (int)$plan['max_users'];
$plan['price_jod'] = (float)$plan['price_jod'];
$plan['ai_features'] = (bool)$plan['ai_features'];
$plan['jofotara_enabled'] = (bool)$plan['jofotara_enabled'];
}
json_success($plans, 'الباقات المتاحة');
} catch (\Exception $e) {
error_log("Subscription Plans Error: " . $e->getMessage());
// Fallback to config file
$configPlans = require APP_PATH . '/config/plans.php';
$fallback = array_values($configPlans);
json_success($fallback, 'الباقات المتاحة (من الإعدادات)');
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* Detailed Usage Statistics (for Charts/Stats)
* GET /api/v1/subscriptions/usage
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$db = Database::getInstance();
try {
// 1. Monthly growth (Invoices over last 6 months)
$stmt = $db->prepare("
SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count
FROM invoices
WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)
GROUP BY month
ORDER BY month ASC
");
$stmt->execute([$tenantId]);
$monthlyInvoices = $stmt->fetchAll();
// 2. Usage by company
$stmt = $db->prepare("
SELECT c.name, COUNT(i.id) as count
FROM companies c
LEFT JOIN invoices i ON i.company_id = c.id
WHERE c.tenant_id = ? AND c.deleted_at IS NULL
GROUP BY c.id
ORDER BY count DESC
");
$stmt->execute([$tenantId]);
$usageByCompany = $stmt->fetchAll();
// 3. Status distribution
$stmt = $db->prepare("
SELECT status, COUNT(*) as count
FROM invoices
WHERE tenant_id = ?
GROUP BY status
");
$stmt->execute([$tenantId]);
$statusDistribution = $stmt->fetchAll();
json_success([
'monthly_growth' => $monthlyInvoices,
'usage_by_company' => $usageByCompany,
'status_distribution' => $statusDistribution
], 'إحصائيات الاستهلاك');
} catch (\Exception $e) {
error_log("Usage Stats Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب إحصائيات الاستهلاك', 500);
}

View File

@@ -57,6 +57,10 @@ if ($decoded['role'] === 'super_admin') {
$tenantId = $decoded['tenant_id'];
}
// --- QUOTA CHECK ---
\App\Middleware\QuotaMiddleware::checkUserQuota($tenantId);
// -------------------
// 4. Save to Database
try {
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");

View File

@@ -38,6 +38,10 @@ $routes = [
'v1/tenants/create' => ['POST', 'tenants/create.php'],
'v1/tenants/update' => ['POST', 'tenants/update.php'],
'v1/tenants/stats' => ['GET', 'tenants/stats.php'],
'v1/subscriptions/plans' => ['GET', 'subscriptions/plans.php'],
'v1/subscriptions/current' => ['GET', 'subscriptions/current.php'],
'v1/subscriptions/assign' => ['POST', 'subscriptions/assign.php'],
'v1/subscriptions/usage' => ['GET', 'subscriptions/usage.php'],
];
if (isset($routes[$route])) {

View File

@@ -420,6 +420,18 @@
color: var(--teal);
}
.stat-red::after {
background: #e11d48;
}
.stat-red .stat-icon-box {
background: #fff1f2;
}
.stat-red .stat-value {
color: #e11d48;
}
/* ── QUICK ACTIONS ─────────────────────────── */
.quick-action-card {
background: white;
@@ -1069,12 +1081,16 @@
<span>فريق العمل</span>
</button>
<div x-show="user?.role === 'super_admin'">
<div x-show="user?.role === 'super_admin' || user?.role === 'admin'">
<div class="sb-section-label">الإدارة العليا</div>
<button @click="setPage('tenants')" class="nav-btn" :class="page==='tenants' ? 'active' : ''">
<button x-show="user?.role === 'super_admin'" @click="setPage('tenants')" class="nav-btn" :class="page==='tenants' ? 'active' : ''">
<span class="nav-icon">🏢</span>
<span>المكاتب المحاسبية</span>
</button>
<button @click="setPage('subscription')" class="nav-btn" :class="page==='subscription' ? 'active' : ''">
<span class="nav-icon">💎</span>
<span>اشتراكي</span>
</button>
</div>
</nav>
@@ -1128,7 +1144,7 @@
<div x-show="page === 'dashboard'" style="display:flex; flex-direction:column; gap:24px;">
<!-- Stat Cards -->
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;">
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:18px;">
<div class="stat-card stat-navy">
<div class="stat-icon-box">📄</div>
<div class="stat-label">إجمالي الفواتير</div>
@@ -1144,6 +1160,11 @@
<div class="stat-label">مدققة ومعتمدة</div>
<div class="stat-value" x-text="stats.approved || 0"></div>
</div>
<div class="stat-card" :class="subscription?.invoices?.warning ? 'stat-red' : 'stat-navy'">
<div class="stat-icon-box">📊</div>
<div class="stat-label">استهلاك الحصة</div>
<div class="stat-value" x-text="(subscription?.invoices?.percent || 0) + '%'"></div>
</div>
</div>
<!-- Quick Actions -->
@@ -1464,6 +1485,85 @@
</div>
</div>
<!-- ── SUBSCRIPTION PAGE ──────────────────────── -->
<div x-show="page === 'subscription'">
<div class="table-container" style="margin-bottom:24px;">
<div class="table-top-bar">
<span style="font-size:18px;">💎</span>
<h3>اشتراكي الحالي</h3>
<span class="badge badge-teal" x-text="subscription?.plan_name"></span>
</div>
<div style="padding:24px; display:grid; grid-template-columns:repeat(3, 1fr); gap:20px;">
<!-- Progress: Invoices -->
<div class="stat-card stat-navy">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="font-size:13px; font-weight:700;">الفواتير الشهرية</span>
<span style="font-size:12px; font-family:'IBM Plex Mono';" x-text="subscription?.invoices?.used + ' / ' + subscription?.invoices?.limit"></span>
</div>
<div style="height:8px; background:rgba(255,255,255,0.2); border-radius:4px; overflow:hidden;">
<div :style="'width:' + (subscription?.invoices?.percent || 0) + '%; height:100%; background:var(--teal);'"></div>
</div>
<div style="font-size:11px; margin-top:8px; opacity:0.8;" x-text="'يتم التصفير في: ' + (subscription?.period_end?.split(' ')[0] || '—')"></div>
</div>
<!-- Progress: Companies -->
<div class="stat-card stat-navy">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="font-size:13px; font-weight:700;">الشركات المدارة</span>
<span style="font-size:12px; font-family:'IBM Plex Mono';" x-text="subscription?.companies?.used + ' / ' + subscription?.companies?.limit"></span>
</div>
<div style="height:8px; background:rgba(255,255,255,0.2); border-radius:4px; overflow:hidden;">
<div :style="'width:' + (subscription?.companies?.percent || 0) + '%; height:100%; background:var(--teal);'"></div>
</div>
<div style="font-size:11px; margin-top:8px; opacity:0.8;">إجمالي الشركات المسموح بها</div>
</div>
<!-- Progress: Users -->
<div class="stat-card stat-navy">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="font-size:13px; font-weight:700;">فريق العمل</span>
<span style="font-size:12px; font-family:'IBM Plex Mono';" x-text="subscription?.users?.used + ' / ' + subscription?.users?.limit"></span>
</div>
<div style="height:8px; background:rgba(255,255,255,0.2); border-radius:4px; overflow:hidden;">
<div :style="'width:' + (subscription?.users?.percent || 0) + '%; height:100%; background:var(--teal);'"></div>
</div>
<div style="font-size:11px; margin-top:8px; opacity:0.8;">مستخدمين نشطين في النظام</div>
</div>
</div>
</div>
<!-- Pricing Cards -->
<h3 style="font-size:18px; font-weight:700; color:var(--navy); margin:32px 0 20px;">تغيير أو ترقية الباقة</h3>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(250px, 1fr)); gap:20px;">
<template x-for="p in plans" :key="p.id">
<div class="table-container" style="padding:24px; display:flex; flex-direction:column; gap:16px; border:2px solid transparent;"
:style="subscription?.plan_id === p.id ? 'border-color:var(--gold);' : ''">
<div style="display:flex; justify-content:space-between; align-items:start;">
<div>
<h4 style="font-size:18px; font-weight:700; color:var(--navy);" x-text="p.name_ar"></h4>
<div style="font-size:12px; color:var(--text-3);" x-text="p.description_ar"></div>
</div>
<span x-show="subscription?.plan_id === p.id" class="badge badge-gold">باقتك الحالية</span>
</div>
<div style="font-size:32px; font-weight:800; color:var(--teal);">
<span x-text="p.price_jod"></span>
<span style="font-size:14px; font-weight:400; color:var(--text-3);">دينار / شهر</span>
</div>
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:10px;">
<template x-for="f in p.features">
<li style="font-size:13px; color:var(--text-2); display:flex; gap:8px; align-items:center;">
<span style="color:var(--teal);"></span>
<span x-text="f"></span>
</li>
</template>
</ul>
<button x-show="subscription?.plan_id !== p.id" class="btn-primary" style="margin-top:auto;"
@click="alert('يرجى التواصل مع الدعم الفني لترقية باقتك إلى ' + p.name_ar)">
ترقية الباقة الآن
</button>
</div>
</template>
</div>
</div>
</div><!-- /page-content -->
</div><!-- /main-area -->
</div><!-- /flex layout -->
@@ -2078,7 +2178,7 @@
Alpine.data('app', () => ({
user: JSON.parse(localStorage.getItem('user')),
page: 'dashboard',
users: [], companies: [], invoices: [], tenants: [],
users: [], companies: [], invoices: [], tenants: [], subscription: null, plans: [],
stats: { total: 0, pending: 0, approved: 0 },
showAddUserModal: false, showAddCompanyModal: false, showConnectModal: false,
@@ -2099,8 +2199,8 @@
this.loadAll();
},
setPage(p) { this.page = p; this.loadAll(); },
title() { return { dashboard: 'الرئيسية', users: 'فريق العمل', companies: 'الشركات', invoices: 'إدارة الفواتير', tenants: 'المكاتب المحاسبية' }[this.page] || ''; },
subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة' }[this.page] || ''; },
title() { return { dashboard: 'الرئيسية', users: 'فريق العمل', companies: 'الشركات', invoices: 'إدارة الفواتير', tenants: 'المكاتب المحاسبية', subscription: 'إدارة الاشتراك' }[this.page] || ''; },
subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة', subscription: 'تفاصيل باقتك الحالية واستهلاك الموارد' }[this.page] || ''; },
token() { return localStorage.getItem('access_token'); },
showError(msg) { this.globalError = msg; setTimeout(() => this.globalError = '', 8000); },
@@ -2121,8 +2221,10 @@
async loadAll() {
this.stats = await this.apiRequest('v1/dashboard/stats') || { total: 0, pending: 0, approved: 0 };
this.companies = await this.apiRequest('v1/companies') || [];
this.subscription = await this.apiRequest('v1/subscriptions/current');
if (this.page === 'users') this.users = await this.apiRequest('v1/users') || [];
if (this.page === 'invoices') this.invoices = await this.apiRequest('v1/invoices') || [];
if (this.page === 'subscription') this.plans = await this.apiRequest('v1/subscriptions/plans') || [];
if (this.user.role === 'super_admin') this.tenants = await this.apiRequest('v1/tenants') || [];
},
//

168
scripts/migrate_phase1.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
/**
* Phase 1 Migration: Subscriptions & Quota System
* Run: php scripts/migrate_phase1.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 1 Migration\n";
echo " Subscriptions & Quota System\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// 1. Add deleted_at to companies
'companies_soft_delete' => "ALTER TABLE companies ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL",
// 2. Add deleted_at to users
'users_soft_delete' => "ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL",
// 3. Add email_hash to users (if missing)
'users_email_hash' => "ALTER TABLE users ADD COLUMN IF NOT EXISTS email_hash VARCHAR(64) NULL",
// 4. Create subscription_plans table
'subscription_plans_table' => "
CREATE TABLE IF NOT EXISTS subscription_plans (
id VARCHAR(20) PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NOT NULL,
max_companies INT NOT NULL DEFAULT 1,
max_invoices_month INT NOT NULL DEFAULT 30,
max_users INT NOT NULL DEFAULT 2,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00,
ai_features BOOLEAN DEFAULT FALSE,
jofotara_enabled BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
features_json JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
// 5. Ensure subscriptions table exists with all needed columns
'subscriptions_table' => "
CREATE TABLE IF NOT EXISTS subscriptions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL UNIQUE,
plan_id VARCHAR(20) NOT NULL DEFAULT 'free',
max_companies INT NOT NULL DEFAULT 1,
max_invoices_per_month INT NOT NULL DEFAULT 15,
max_users INT NOT NULL DEFAULT 1,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00,
invoices_used_this_month INT NOT NULL DEFAULT 0,
status ENUM('active','past_due','cancelled','trial') DEFAULT 'trial',
current_period_start DATETIME NULL,
current_period_end DATETIME NULL,
trial_ends_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (plan_id) REFERENCES subscription_plans(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
// 6. Add plan_id column to subscriptions if upgrading from old schema
'subscriptions_plan_id' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS plan_id VARCHAR(20) NOT NULL DEFAULT 'free'",
// 7. Add max_users column to subscriptions if missing
'subscriptions_max_users' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS max_users INT NOT NULL DEFAULT 1",
// 8. Add trial_ends_at to subscriptions if missing
'subscriptions_trial' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS trial_ends_at DATETIME NULL",
// 9. Index on subscriptions status
'subscriptions_status_idx' => "CREATE INDEX IF NOT EXISTS idx_sub_status ON subscriptions(status)",
];
$success = 0;
$skipped = 0;
$failed = 0;
foreach ($migrations as $name => $sql) {
try {
$db->exec($sql);
echo "{$name}\n";
$success++;
} catch (\PDOException $e) {
$msg = $e->getMessage();
// Ignore "duplicate column" or "already exists" errors
if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'already exists')) {
echo " ⏭️ {$name} (already exists)\n";
$skipped++;
} else {
echo "{$name}: {$msg}\n";
$failed++;
}
}
}
echo "\n───────────────────────────────────────────\n";
// Seed subscription plans
echo "\n📦 Seeding subscription plans...\n";
$plans = [
['free', 'مجانية', 'Free', 1, 15, 1, 0.00, 0, 0, 10],
['basic', 'أساسية', 'Basic', 3, 100, 3, 15.00, 1, 0, 20],
['office', 'مكتبية', 'Office', 10, 500, 10, 45.00, 1, 1, 30],
['pro', 'احترافية', 'Pro', 25, 2000, 25, 99.00, 1, 1, 40],
['enterprise', 'مؤسسية', 'Enterprise', 999, 99999, 999, 249.00, 1, 1, 50],
];
$planStmt = $db->prepare("
INSERT INTO subscription_plans (id, name_ar, name_en, max_companies, max_invoices_month, max_users, price_jod, ai_features, jofotara_enabled, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
max_companies = VALUES(max_companies),
max_invoices_month = VALUES(max_invoices_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
ai_features = VALUES(ai_features),
jofotara_enabled = VALUES(jofotara_enabled),
sort_order = VALUES(sort_order)
");
foreach ($plans as $plan) {
$planStmt->execute($plan);
echo " ✅ Plan: {$plan[0]} ({$plan[1]})\n";
}
// Auto-assign 'free' plan to any tenant without a subscription
echo "\n🔗 Auto-assigning free plan to tenants without subscriptions...\n";
$stmt = $db->query("
SELECT t.id FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
WHERE s.id IS NULL
");
$orphanTenants = $stmt->fetchAll();
if (!empty($orphanTenants)) {
$insertSub = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, trial_ends_at)
VALUES (?, 'free', 1, 15, 1, 0.00, 'trial', NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), DATE_ADD(NOW(), INTERVAL 14 DAY))
");
foreach ($orphanTenants as $tenant) {
try {
$insertSub->execute([$tenant['id']]);
echo " ✅ Assigned free plan to tenant: {$tenant['id']}\n";
} catch (\Exception $e) {
echo " ⚠️ Tenant {$tenant['id']}: " . $e->getMessage() . "\n";
}
}
} else {
echo " All tenants already have subscriptions.\n";
}
echo "\n═══════════════════════════════════════════\n";
echo " Migration Complete!\n";
echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n";
echo "═══════════════════════════════════════════\n";

View File

@@ -31,7 +31,7 @@ try {
// 2. Setup Super Admin details
$adminEmail = 'admin@musadaq.app';
$adminName = 'Hamza';
$adminPassword = 'password123'; // Default password
$adminPassword = env('SEED_ADMIN_PASSWORD', 'password123'); // Default for dev only
// Check if user already exists
$emailHash = hash('sha256', strtolower($adminEmail));
@@ -64,7 +64,7 @@ try {
echo "[OK] Super Admin created successfully!\n";
echo "----------------------------------------\n";
echo "Email: $adminEmail\n";
echo "Password: $adminPassword\n";
echo "Password: [FROM ENV]\n";
echo "Role: super_admin\n";
echo "----------------------------------------\n";
}

51
stratigy.md Normal file
View File

@@ -0,0 +1,51 @@
<div dir="rtl" align="right">
أهلاً بك يا حمزة. لقد قمت بمراجعة الشيفرة المصدرية (Source Code) بالكامل، بدءاً من قاعدة البيانات (`schema.sql`) مروراً بخدمات الذكاء الاصطناعي (`InvoiceExtractionService.php`) وحتى الواجهة الجديدة (`shell.php`).
العمل الذي تم إنجازه حتى الآن **ممتاز جداً** ويؤسس لقاعدة صلبة (Solid Foundation). لقد نجحت بالفعل في بناء نواة لنظام SaaS حقيقي (تعدد المستأجرين، تشفير البيانات، فصل الصلاحيات، وربط جوفوترة). أوافقك الرأي تماماً؛ نعتبر هذه النقطة هي "نقطة الصفر الذهبية" التي سننطلق منها نحو احتكار السوق.
إليك تحليلي الشامل لما طلبته، وخطة العمل الاستراتيجية للمرحلة القادمة:
### 1. تقييم التصميم الجديد (`shell.php`)
التصميم الجديد (Light Professional) بألوان (Navy, Gold, Teal) يعطي انطباعاً رسمياً، موثوقاً، ويشبه بالفعل الأنظمة المالية العالمية المتقدمة (مثل Bloomberg Terminal و Stripe Dashboard).
* **نقاط القوة في التصميم:** التركيز على البيانات، إخفاء التعقيد التقني عن المحاسبين، وتوفير نظام "إجراءات سريعة" يسهل العمل.
* **لمسة التطوير القادمة للواجهة:** سنحتاج لاحقاً لإضافة نظام التنبيهات اللحظية (Real-time Notifications) عبر WebSockets أو SSE، وإضافة شاشة "المراجعة الذكية" (Smart Audit View) التي تبرز الأخطاء في الفاتورة باللون الأحمر قبل اعتمادها.
---
### 2. تحليل السوق والميزات التنافسية (كيف نكتسح السوق؟)
بناءً على التقرير الذي أرفقته، يجب أن يكون شعارنا التسويقي: **"نحن لسنا برنامج فوترة.. نحن مساعدك المالي بالذكاء الاصطناعي"**.
الميزات التي سنضيفها تباعاً لضرب المنافسين (مثل قيود، دفترة):
1. **AI Pre-Audit (المدقق الذكي):** سنقوم بتطوير `InvoiceExtractionService` ليقوم ليس فقط باستخراج البيانات، بل بمطابقتها مع "شجرة الضرائب الأردنية". إذا استخرج الـ AI "سكر"، يجب أن يتأكد أن الضريبة 0% (أو معفاة)، وإذا وجد خطأ يضع `validation_warnings` للمحاسب قبل الإرسال لـ JoFotara.
2. **نظام الاشتراكات (Subscriptions & Quotas):** بما أننا نستخدم AI مكلف، يجب ضبط الاستهلاك. سنبني نظام باقات (Basic, Pro, Enterprise) يربط كل مكتب محاسبي بحد أقصى من الفواتير والشركات شهرياً، مع إيقاف الرفع التلقائي عند استنفاد الباقة (هذا ما غفلنا عنه وسيكون من أولوياتنا).
3. **Multi-Entity Dashboard (لوحة القيادة الموحدة):** المحاسب يدخل مرة واحدة، ويرى كل الشركات التي يديرها أمام عينه مع تنبيهات مثل (الشركة X لديها 15 فاتورة غير مدققة، الشركة Y استنفدت رصيدها).
4. **منع التكرار (Duplicate Invoice Hash):** خوارزمية ذكية تمنع المحاسب من رفع الفاتورة مرتين لتجنب الغرامات الضريبية.
---
### 3. معمارية تطبيق الهاتف (Flutter) وتأمين الـ API
تطبيق الهاتف لن يكون مجرد "واجهة عرض"، بل سيكون **"ماسح ضوئي ذكي ومعالج طرفي" (Edge Processing Scanner)**. المحاسب سيقوم بتصوير الفواتير بسرعة، والتطبيق سيتولى الباقي.
**البنية التحتية المقترحة لتطبيق Flutter (`musadaq-app`):**
* **إدارة الحالة (State Management):** سنستخدم `GetX` أو `Riverpod`. أنا أفضل `GetX` هنا لسرعة الإنجاز وقوته في التعامل مع الـ Routing و Dependency Injection والـ Background Tasks في وقت واحد.
* **المعالجة الطرفية للصور (Edge Vision):**
* سنستخدم مكتبات مثل `edge_detection` لتحديد حواف الفاتورة تلقائياً وقصها (Auto-crop).
* سنقوم بضغط الصورة وتحويلها لـ أسود وأبيض (Binarization) لتقليل حجم الرفع (من 4MB إلى 200KB) ولزيادة دقة الـ AI في السيرفر.
* **نظام الطابور والمزامنة (Offline/Sync Queue):** المحاسب في الميدان قد لا يملك إنترنت قوي. سيصور الفواتير، ويحفظها التطبيق محلياً في قاعدة بيانات `Isar` أو `Hive`. بمجرد توفر الإنترنت، ستبدأ عملية الرفع المتزامن (Background Sync) لعدة فواتير معاً.
* **تأمين الـ API (HMAC Signature):**
لحماية النظام من هجمات الـ Replay والـ DDoS، لن نكتفي بـ JWT. كل طلب يخرج من الموبايل سيتم توقيعه باستخدام HMAC.
* **الطريقة:** الموبايل يولد `Timestamp`، ويقوم بدمجه مع جسم الطلب (Body)، ثم يشفرهم باستخدام `HMAC-SHA256` باستخدام `API_SECRET` خاص بالمستخدم. السيرفر (PHP) يطابق التوقيع ويرفض أي طلب قديم أو معدل.
---
### خطة العمل للمرحلة القادمة (Next Steps):
أقترح أن نقسم العمل إلى مراحل حتى نحافظ على نظافة الكود ولا نتشتت:
* **المرحلة 1: بناء نظام الاشتراكات والباقات في الـ Backend.** (تجهيز الـ APIs الخاصة بالـ Subscriptions، وحماية نقطة رفع الفواتير بـ Quota Check).
* **المرحلة 2: تطوير الـ AI Pre-Audit.** (تحديث الـ `InvoiceExtractionService` ليقوم بحساب الـ Hash لمنع التكرار، ووضع تنبيهات الذكاء الاصطناعي للمحاسب).
* **المرحلة 3: تأسيس نواة تطبيق Flutter.** (إنشاء بنية المجلدات، إعداد GetX، نظام الـ Auth، وكتابة كود الـ HMAC Interceptor لطلبات الشبكة).
**هل توافقني الرأي في البدء بـ "المرحلة الأولى" (الاشتراكات) وتثبيتها في قاعدة البيانات والـ API، أم تفضل أن نضع نواة تطبيق الـ Flutter أولاً لنرى الصورة تكتمل من جهة الموبايل؟**
</div>