- Add comprehensive feature limits enforcement middleware - Implement subscription dashboard with usage analytics - Create reusable plan card component with feature badges - Add trial configuration support with limit overrides - Fix payment controller null safety issues - Improve pricing page UI with proper feature display
290 lines
9.1 KiB
PHP
290 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Models\Plan;
|
|
use App\Models\PlanUsage;
|
|
use App\Models\Subscription;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class EnforceFeatureLimits
|
|
{
|
|
/**
|
|
* Handle an incoming request and enforce feature limits.
|
|
*
|
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
|
*/
|
|
public function handle(Request $request, Closure $next, ?string $featureName = null): Response
|
|
{
|
|
// Skip enforcement for guest users
|
|
if (! Auth::check()) {
|
|
return $next($request);
|
|
}
|
|
|
|
// Skip enforcement for admin users (configurable)
|
|
if ($this->isAdminUser()) {
|
|
return $next($request);
|
|
}
|
|
|
|
// Get the user's active subscription
|
|
$subscription = $this->getActiveSubscription();
|
|
if (! $subscription) {
|
|
return $this->handleNoSubscription($request);
|
|
}
|
|
|
|
// Get the plan with features
|
|
$plan = $subscription->plan->load(['planFeatureLimits.planFeature']);
|
|
if (! $plan) {
|
|
Log::error('No plan found for subscription', ['subscription_id' => $subscription->id]);
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
// Check if we're enforcing a specific feature
|
|
if ($featureName) {
|
|
return $this->enforceSpecificFeature($request, $next, $plan, $subscription, $featureName);
|
|
}
|
|
|
|
// General usage tracking for common features
|
|
$this->trackGeneralUsage($request, $plan, $subscription);
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
/**
|
|
* Enforce limits for a specific feature
|
|
*/
|
|
private function enforceSpecificFeature(
|
|
Request $request,
|
|
Closure $next,
|
|
Plan $plan,
|
|
Subscription $subscription,
|
|
string $featureName
|
|
): Response {
|
|
// Check if user can use the feature
|
|
$currentUsage = $this->getCurrentUsage($subscription->id, $featureName);
|
|
$isOnTrial = (bool) ($subscription?->onTrial() ?? false);
|
|
|
|
if (! $plan->canUseFeature($featureName, $currentUsage, $isOnTrial)) {
|
|
return $this->handleFeatureLimitExceeded($request, $plan, $featureName, $currentUsage);
|
|
}
|
|
|
|
// Track usage if this is a usage-generating request
|
|
if ($this->isUsageGeneratingRequest($request)) {
|
|
$this->incrementUsage($subscription->id, $featureName);
|
|
}
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
/**
|
|
* Get user's active subscription
|
|
*/
|
|
private function getActiveSubscription(): ?Subscription
|
|
{
|
|
return Auth::user()->subscriptions()
|
|
->whereIn('status', ['active', 'trialing'])
|
|
->where('ends_at', '>', now())
|
|
->with('plan')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* Handle requests from users with no subscription
|
|
*/
|
|
private function handleNoSubscription(Request $request): Response
|
|
{
|
|
// Allow access to non-protected routes
|
|
$allowedRoutes = [
|
|
'pricing',
|
|
'checkout.*',
|
|
'login',
|
|
'register',
|
|
'home',
|
|
'dashboard.pricing',
|
|
];
|
|
|
|
foreach ($allowedRoutes as $route) {
|
|
if ($request->routeIs($route)) {
|
|
return $next($request);
|
|
}
|
|
}
|
|
|
|
// Redirect to pricing page for other routes
|
|
if ($request->expectsJson()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Subscription required',
|
|
'message' => 'Please subscribe to access this feature.',
|
|
'redirect_url' => route('pricing'),
|
|
], 402);
|
|
}
|
|
|
|
return redirect()->route('pricing')
|
|
->with('error', 'Please subscribe to access this feature.');
|
|
}
|
|
|
|
/**
|
|
* Handle when feature limit is exceeded
|
|
*/
|
|
private function handleFeatureLimitExceeded(
|
|
Request $request,
|
|
Plan $plan,
|
|
string $featureName,
|
|
float $currentUsage
|
|
): Response {
|
|
$feature = $plan->planFeatureLimits
|
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
|
$query->where('name', $featureName);
|
|
})
|
|
->first();
|
|
|
|
$featureDisplayName = $feature?->planFeature->display_name ?? $featureName;
|
|
$limit = $feature?->limit_value ?? 'Unknown';
|
|
$remaining = max(0, ($limit ?? 0) - $currentUsage);
|
|
|
|
Log::info('Feature limit exceeded', [
|
|
'user_id' => Auth::id(),
|
|
'plan_id' => $plan->id,
|
|
'feature' => $featureName,
|
|
'current_usage' => $currentUsage,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
if ($request->expectsJson()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Feature limit exceeded',
|
|
'message' => "You have reached your limit for {$featureDisplayName}. Current usage: {$currentUsage}, Limit: {$limit}",
|
|
'feature' => $featureName,
|
|
'current_usage' => $currentUsage,
|
|
'limit' => $limit,
|
|
'remaining' => $remaining,
|
|
'upgrade_url' => $this->getUpgradeUrl($plan),
|
|
], 429);
|
|
}
|
|
|
|
return back()->with('error', "You have reached your limit for {$featureDisplayName}.
|
|
Current usage: {$currentUsage}, Limit: {$limit}.
|
|
<a href='{$this->getUpgradeUrl($plan)}' class='text-blue-600 hover:underline'>Upgrade your plan</a> to increase limits.");
|
|
}
|
|
|
|
/**
|
|
* Track general usage for common features
|
|
*/
|
|
private function trackGeneralUsage(Request $request, Plan $plan, Subscription $subscription): void
|
|
{
|
|
// Track API calls
|
|
if ($request->is('api/*') && $plan->hasFeature('api_access')) {
|
|
$this->incrementUsage($subscription->id, 'api_access');
|
|
}
|
|
|
|
// Track email operations
|
|
if ($this->isEmailOperation($request) && $plan->hasFeature('email_sending')) {
|
|
$this->incrementUsage($subscription->id, 'email_sending');
|
|
}
|
|
|
|
// Track advanced filters usage
|
|
if ($this->isFilterOperation($request) && $plan->hasFeature('advanced_filters')) {
|
|
$this->incrementUsage($subscription->id, 'advanced_filters');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if request generates usage
|
|
*/
|
|
private function isUsageGeneratingRequest(Request $request): bool
|
|
{
|
|
// POST, PUT, PATCH requests typically generate usage
|
|
return in_array($request->method(), ['POST', 'PUT', 'PATCH']);
|
|
}
|
|
|
|
/**
|
|
* Check if this is an email operation
|
|
*/
|
|
private function isEmailOperation(Request $request): bool
|
|
{
|
|
return $request->is(['api/emails/*', 'emails/*']) ||
|
|
str_contains($request->path(), 'email') ||
|
|
$request->has('to') || $request->has('subject');
|
|
}
|
|
|
|
/**
|
|
* Check if this is a filter operation
|
|
*/
|
|
private function isFilterOperation(Request $request): bool
|
|
{
|
|
return $request->has('filter') ||
|
|
$request->has('filters') ||
|
|
str_contains($request->path(), 'filter');
|
|
}
|
|
|
|
/**
|
|
* Get current usage for a feature
|
|
*/
|
|
private function getCurrentUsage(int $subscriptionId, string $featureName): float
|
|
{
|
|
$usage = PlanUsage::where('subscription_id', $subscriptionId)
|
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
|
$query->where('name', $featureName);
|
|
})
|
|
->where('period_type', 'monthly')
|
|
->whereMonth('created_at', now()->month)
|
|
->whereYear('created_at', now()->year)
|
|
->sum('usage_value');
|
|
|
|
return (float) $usage;
|
|
}
|
|
|
|
/**
|
|
* Increment usage for a feature
|
|
*/
|
|
private function incrementUsage(int $subscriptionId, string $featureName): void
|
|
{
|
|
$subscription = Subscription::find($subscriptionId);
|
|
$feature = $subscription->plan->planFeatureLimits
|
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
|
$query->where('name', $featureName);
|
|
})
|
|
->first();
|
|
|
|
if (! $feature) {
|
|
return;
|
|
}
|
|
|
|
PlanUsage::updateOrCreate([
|
|
'subscription_id' => $subscriptionId,
|
|
'plan_id' => $subscription->plan_id,
|
|
'user_id' => Auth::id(),
|
|
'plan_feature_id' => $feature->plan_feature_id,
|
|
'period_type' => $feature->limit_type === 'boolean' ? 'total' : 'monthly',
|
|
'created_at' => now()->startOfMonth(),
|
|
], [
|
|
'usage_value' => \DB::raw('usage_value + 1'),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check if user is admin (bypasses limits)
|
|
*/
|
|
private function isAdminUser(): bool
|
|
{
|
|
return Auth::user() && Auth::user()->level >= 10;
|
|
}
|
|
|
|
/**
|
|
* Get upgrade URL for plan
|
|
*/
|
|
private function getUpgradeUrl(Plan $currentPlan): string
|
|
{
|
|
$upgradePaths = $currentPlan->getUpgradePath();
|
|
|
|
return count($upgradePaths) > 0 ? route('pricing') : route('pricing');
|
|
}
|
|
}
|