Files
musadaq-saas/app/Middleware/QuotaMiddleware.php
T

338 lines
13 KiB
PHP

<?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;
use App\Core\Cache;
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, int $needed = 1): array
{
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
// $db is needed by the auto-reset branch below regardless of whether the
// subscription came from the cache, so resolve it unconditionally.
$db = Database::getInstance();
if ($sub === false || $sub === null) {
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
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) {
Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes
}
}
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 period 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');
$cycle = $sub['billing_cycle'] ?? 'annual';
$interval = ($cycle === 'monthly') ? '+1 month' : '+1 year';
$newEnd = date('Y-m-d H:i:s', strtotime($interval));
$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;
// The cached copy still holds the pre-reset counters.
Cache::delete($cacheKey);
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
}
// Check invoice quota. $needed lets callers reserve a whole batch at once
// instead of only asking "is there room for one more?".
$used = (int)$sub['invoices_used_this_month'];
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
$needed = max(1, $needed);
if (($used + $needed) > $limit) {
$remaining = max(0, $limit - $used);
$message = $remaining === 0
? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.'
: 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.';
json_error($message, 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
'requested' => $needed,
'remaining' => $remaining,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'period_end' => $sub['current_period_end'],
]);
}
return $sub;
}
/**
* Non-fatal variant of checkInvoiceQuota() for background workers.
*
* Workers must never emit an HTTP response, so this returns a boolean
* instead of calling json_error().
*/
public static function hasInvoiceQuota(string $tenantId, int $needed = 1): bool
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("
SELECT invoices_used_this_month, max_invoices_per_month, status
FROM subscriptions WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) return false;
if (in_array($sub['status'], ['cancelled', 'past_due'], true)) return false;
return ((int)$sub['invoices_used_this_month'] + max(1, $needed))
<= (int)$sub['max_invoices_per_month'];
} catch (\Throwable $e) {
error_log('[QuotaMiddleware] hasInvoiceQuota failed: ' . $e->getMessage());
return false;
}
}
/**
* 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]);
// Invalidate cache
Cache::delete("quota_sub_{$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);
// Check for pending payment request
$stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$pendingPayment = $stmt->fetch();
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),
'pending_payment' => $pendingPayment ? [
'id' => $pendingPayment['id'],
'plan_id' => $pendingPayment['plan_id'],
'reference' => $pendingPayment['internal_reference']
] : null,
'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,
];
}
}