feat: enhance pricing page with feature limits and trial management

- 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
This commit is contained in:
idevakk
2025-11-21 10:55:57 -08:00
parent b497f7796d
commit 72b8109a3a
9 changed files with 1533 additions and 142 deletions

View File

@@ -12,7 +12,7 @@ use Illuminate\Validation\ValidationException;
class PaymentController extends Controller
{
public function __construct(
private PaymentOrchestrator $orchestrator
private readonly PaymentOrchestrator $orchestrator
) {}
/**
@@ -25,12 +25,55 @@ class PaymentController extends Controller
'plan_id' => 'required|exists:plans,id',
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar,oxapay,crypto,activation_key',
'options' => 'nullable|array',
'is_trial' => 'nullable|boolean',
]);
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
$plan = Plan::with(['planProviders', 'trialConfiguration'])->findOrFail($validated['plan_id']);
$provider = $validated['provider'] ?? null;
$options = $validated['options'] ?? [];
$isTrial = $validated['is_trial'] ?? false;
// Validate provider support
if ($provider && ! $plan?->supportsProvider($provider)) {
return response()->json([
'success' => false,
'error' => "Provider '{$provider}' is not supported for this plan.",
], 400);
}
// Validate trial requirements
if ($isTrial) {
if (! $plan?->hasTrial()) {
return response()->json([
'success' => false,
'error' => 'This plan does not offer trials.',
], 400);
}
$trialConfig = $plan?->getTrialConfig();
if ($trialConfig && $trialConfig->trial_requires_payment_method && ! $provider) {
return response()->json([
'success' => false,
'error' => 'Payment method is required for trial. Please specify a provider.',
], 400);
}
}
// Enhance options with plan-specific data
$options = array_merge($options, [
'is_trial' => $isTrial,
'plan_features' => $plan?->getFeaturesWithLimits() ?? [],
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
'plan_tier' => $plan?->planTier?->name,
]);
if (! $plan) {
return response()->json([
'success' => false,
'error' => 'Plan not found.',
], 404);
}
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
@@ -70,13 +113,20 @@ class PaymentController extends Controller
$options = $validated['options'] ?? [];
// Only recurring providers can create subscriptions
if (! $plan->monthly_billing) {
if (! $plan?->monthly_billing) {
return response()->json([
'success' => false,
'error' => 'This plan does not support recurring subscriptions. Use checkout instead.',
], 400);
}
if (! $plan) {
return response()->json([
'success' => false,
'error' => 'Plan not found.',
], 404);
}
$result = $this->orchestrator->createSubscription($user, $plan, $provider, $options);
return response()->json([
@@ -108,6 +158,14 @@ class PaymentController extends Controller
]);
$plan = Plan::findOrFail($validated['plan_id']);
if (! $plan) {
return response()->json([
'success' => false,
'error' => 'Plan not found.',
], 404);
}
$providers = $this->orchestrator->getActiveProvidersForPlan($plan);
$methods = $providers->map(function ($provider) use ($plan) {
@@ -117,7 +175,7 @@ class PaymentController extends Controller
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'fees' => $provider->calculateFees($plan->price),
'fees' => $provider->calculateFees($plan?->price ?? 0),
'active' => $provider->isActive(),
];
})->values()->toArray();
@@ -126,10 +184,10 @@ class PaymentController extends Controller
'success' => true,
'data' => [
'plan' => [
'id' => $plan->id,
'name' => $plan->name,
'price' => $plan->price,
'monthly_billing' => $plan->monthly_billing,
'id' => $plan?->id,
'name' => $plan?->name,
'price' => $plan?->price,
'monthly_billing' => $plan?->monthly_billing,
],
'payment_methods' => $methods,
],
@@ -237,4 +295,302 @@ class PaymentController extends Controller
], 400);
}
}
/**
* Start a trial subscription
*/
public function startTrial(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
'provider' => 'required|string|in:stripe,lemon_squeezy,polar',
'options' => 'nullable|array',
]);
$user = $request->user();
$plan = Plan::with(['trialConfiguration', 'planProviders'])->findOrFail($validated['plan_id']);
$provider = $validated['provider'];
$options = $validated['options'] ?? [];
// Validate trial availability
if (! $plan?->hasTrial()) {
return response()->json([
'success' => false,
'error' => 'This plan does not offer trials.',
], 400);
}
// Validate provider support
if (! $plan?->supportsProvider($provider)) {
return response()->json([
'success' => false,
'error' => "Provider '{$provider}' is not supported for this plan.",
], 400);
}
$trialConfig = $plan?->getTrialConfig();
// Check if user already has an active trial for this plan
$existingTrial = $user->subscriptions()
->where('plan_id', $plan?->id)
->where('status', 'trialing')
->first();
if ($existingTrial) {
return response()->json([
'success' => false,
'error' => 'You already have an active trial for this plan.',
], 400);
}
if (! $plan) {
return response()->json([
'success' => false,
'error' => 'Plan not found.',
], 404);
}
// Create trial subscription using checkout session with trial options
$trialOptions = array_merge($options, [
'is_trial' => true,
'trial_duration_days' => $trialConfig?->trial_duration_days ?? 14,
'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true,
]);
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $trialOptions);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get plan comparison data
*/
public function getPlanComparison(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_ids' => 'required|array|min:1|max:5',
'plan_ids.*' => 'exists:plans,id',
]);
$plans = Plan::with([
'planFeatureLimits.planFeature',
'planProviders',
'trialConfiguration',
'planTier',
])
->whereIn('id', $validated['plan_ids'])
->active()
->ordered()
->get();
$comparison = $plans->map(function ($plan) {
$trialConfig = $plan?->getTrialConfig();
return [
'id' => $plan?->id,
'name' => $plan?->name,
'description' => $plan?->description,
'price' => $plan?->price,
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
'tier' => $plan?->planTier?->name,
'providers' => $plan?->getAllowedProviders() ?? [],
'features' => $plan?->getFeaturesWithLimits() ?? [],
'trial' => $plan?->hasTrial() ? [
'available' => true,
'duration_days' => $trialConfig?->trial_duration_days ?? 0,
'requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? false,
] : ['available' => false],
];
});
return response()->json([
'success' => true,
'data' => $comparison,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get upgrade paths for user's current subscription
*/
public function getUpgradePaths(Request $request): JsonResponse
{
try {
$user = $request->user();
$currentSubscription = $user->subscription('default');
if (! $currentSubscription) {
return response()->json([
'success' => false,
'error' => 'No active subscription found.',
], 404);
}
$currentPlan = $currentSubscription->plan;
$upgradePaths = Plan::with(['planTier', 'planFeatureLimits.planFeature'])
->where('id', '!=', $currentPlan->id)
->where(function ($query) use ($currentPlan) {
$query->where('price', '>', $currentPlan->price)
->orWhereHas('planTier', function ($q) use ($currentPlan) {
$q->where('sort_order', '>', $currentPlan->planTier?->sort_order ?? 0);
});
})
->active()
->ordered()
->get();
$paths = $upgradePaths->map(function ($plan) use ($currentPlan) {
return [
'plan' => [
'id' => $plan->id,
'name' => $plan->name,
'price' => $plan->price,
'billing_cycle' => $plan->getBillingCycleDisplay(),
'tier' => $plan->planTier?->name,
],
'upgrade_benefits' => $this->getUpgradeBenefits($currentPlan, $plan),
'providers' => $plan->getAllowedProviders(),
'can_migrate' => true,
];
});
return response()->json([
'success' => true,
'data' => [
'current_plan' => [
'id' => $currentPlan->id,
'name' => $currentPlan->name,
'price' => $currentPlan->price,
],
'upgrade_paths' => $paths,
],
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get upgrade benefits between two plans
*/
private function getUpgradeBenefits(Plan $currentPlan, Plan $newPlan): array
{
$currentFeatures = $currentPlan->getFeaturesWithLimits();
$newFeatures = $newPlan->getFeaturesWithLimits();
$benefits = [];
foreach ($newFeatures as $featureData) {
$feature = $featureData['feature'];
$newLimit = $featureData['limit'];
$currentLimit = collect($currentFeatures)
->firstWhere('feature.id', $feature->id)['limit'] ?? null;
if (! $currentLimit || $this->isUpgradeBenefit($currentLimit, $newLimit)) {
$benefits[] = [
'feature' => $feature->display_name,
'from' => $this->formatLimitDisplay($currentLimit),
'to' => $this->formatLimitDisplay($newLimit),
'improvement' => $this->getImprovementType($currentLimit, $newLimit),
];
}
}
return $benefits;
}
/**
* Check if a limit change is an upgrade benefit
*/
private function isUpgradeBenefit($currentLimit, $newLimit): bool
{
if (! $currentLimit) {
return true;
}
if (! $newLimit) {
return false;
}
// Boolean upgrades
if ($newLimit->limit_type === 'boolean') {
return ! $currentLimit->limit_value && $newLimit->limit_value;
}
// Numeric upgrades
if ($newLimit->limit_type === 'numeric') {
return ($newLimit->limit_value ?? 0) > ($currentLimit->limit_value ?? 0);
}
return false;
}
/**
* Format limit value for display
*/
private function formatLimitDisplay($limit): string
{
if (! $limit) {
return 'Not Available';
}
if ($limit->limit_type === 'boolean') {
return $limit->limit_value ? 'Enabled' : 'Disabled';
}
if ($limit->limit_type === 'numeric') {
return $limit->limit_value ? (string) $limit->limit_value : 'Unlimited';
}
return 'Limited';
}
/**
* Get improvement type for upgrade benefit
*/
private function getImprovementType($currentLimit, $newLimit): string
{
if (! $currentLimit) {
return 'New Feature';
}
if ($newLimit->limit_type === 'boolean') {
return 'Enabled';
}
if ($newLimit->limit_type === 'numeric') {
return 'Increased Limit';
}
return 'Improved';
}
}

View File

@@ -0,0 +1,289 @@
<?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');
}
}

View File

@@ -2,28 +2,91 @@
namespace App\Livewire\Dashboard;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use App\Models\ActivationKey;
use App\Models\Plan;
use App\Models\PlanTier;
use App\Services\Payments\PaymentOrchestrator;
use Exception;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
class Pricing extends Component
{
public $plans;
public $planTiers;
public $activation_key;
public $selectedProvider = 'stripe';
public $selectedBillingCycle = null;
public $selectedTier = null;
public function mount(): void
{
$this->plans = config('app.plans');
$this->loadPlans();
$this->planTiers = PlanTier::with('plans')->orderBy('sort_order')->get();
}
public function choosePlan($pricing_id): void
private function loadPlans(): void
{
$this->redirect(route('checkout', $pricing_id));
$query = Plan::active()
->ordered()
->with(['planFeatureLimits.planFeature', 'planProviders', 'trialConfiguration', 'planTier']);
if ($this->selectedTier) {
$query->where('plan_tier_id', $this->selectedTier);
}
$this->plans = $query->get();
}
public function filterByTier($tierId = null): void
{
$this->selectedTier = $tierId;
$this->loadPlans();
}
public function choosePlan($planId, $provider = 'stripe'): void
{
$plan = Plan::findOrFail($planId);
if (! $plan?->supportsProvider($provider)) {
session()->flash('error', "This plan doesn't support {$provider} payments.");
return;
}
$this->redirect(route('checkout.enhanced', [
'plan' => $planId,
'provider' => $provider,
]));
}
public function startTrial($planId, $provider = 'stripe'): void
{
$plan = Plan::findOrFail($planId);
if (! $plan?->hasTrial()) {
session()->flash('error', "This plan doesn't offer trials.");
return;
}
if (! $plan?->supportsProvider($provider)) {
session()->flash('error', "This plan doesn't support {$provider} payments for trials.");
return;
}
$this->redirect(route('checkout.trial', [
'plan' => $planId,
'provider' => $provider,
]));
}
public function activateKey(): void
@@ -42,53 +105,125 @@ class Pricing extends Component
->first();
if ($activation) {
if ($activation->price_id !== null) {
$result = $this->addSubscription($activation->price_id);
}
if ($result) {
$activation->is_activated = true;
$activation->user_id = auth()->id();
$activation->save();
session()->flash('success', 'Activation key is valid and has been activated. Refresh page to see changes.');
$this->reset('activation_key');
} else {
session()->flash('error', 'Something went wrong. Kindly drop a mail at contact@zemail.me to activate your subscription manually.');
try {
$result = $this->activateSubscriptionKey($activation);
if ($result) {
session()->flash('success', 'Activation key is valid and has been activated. Refresh page to see changes.');
$this->reset('activation_key');
} else {
session()->flash('error', 'Something went wrong. Kindly drop a mail at contact@zemail.me to activate your subscription manually.');
}
} catch (Exception $e) {
Log::error('Activation key error: '.$e->getMessage());
session()->flash('error', 'An error occurred while activating your key. Please contact support.');
}
} else {
session()->flash('error', 'Invalid or already activated key.');
}
}
private function addSubscription($price_id): bool
private function activateSubscriptionKey(ActivationKey $activation): bool
{
try {
$plan = Plan::query()->where('pricing_id', $price_id)->firstOrFail();
// Use PaymentOrchestrator for activation key processing
$orchestrator = app(PaymentOrchestrator::class);
// Find the plan associated with this activation key
$plan = null;
if ($activation->plan_id) {
$plan = Plan::find($activation->plan_id);
} elseif ($activation->price_id) {
// Fallback to legacy pricing_id lookup
$plan = Plan::where('pricing_id', $activation->price_id)->first();
}
if (! $plan) {
Log::error('No plan found for activation key: '.$activation->id);
return false;
}
// Create subscription using orchestrator
$user = auth()->user();
$user->createOrGetStripeCustomer();
$user->updateStripeCustomer([
'address' => [
'postal_code' => '10001',
'country' => 'US',
],
'name' => $user->name,
'email' => $user->email,
]);
$user->creditBalance($plan->price * 100, 'Premium Top-up for plan: '.$plan->name);
$balance = $user->balance();
$user->newSubscription('default', $plan->pricing_id)->create();
$subscription = $orchestrator->createSubscriptionFromActivationKey($user, $activation, $plan);
$ends_at = $plan->monthly_billing == 1 ? now()->addMonth() : now()->addYear();
$user->subscription('default')->cancelAt($ends_at);
if ($subscription) {
$activation->is_activated = true;
$activation->user_id = $user->id;
$activation->save();
return true;
return true;
}
return false;
} catch (Exception $e) {
Log::error($e->getMessage());
Log::error('Activation key processing failed: '.$e->getMessage());
return false;
}
}
/**
* Get available providers for a plan
*/
public function getPlanProviders($planId): array
{
$plan = $this->plans->firstWhere('id', $planId);
return $plan ? $plan->getAllowedProviders() : [];
}
/**
* Get plan features with limits
*/
public function getPlanFeatures($planId): array
{
$plan = $this->plans->firstWhere('id', $planId);
if (! $plan) {
return [];
}
return $plan->getFeaturesWithLimits();
}
/**
* Check if plan has trial available
*/
public function planHasTrial($planId): bool
{
$plan = $this->plans->firstWhere('id', $planId);
return $plan ? $plan->hasTrial() : false;
}
/**
* Get trial configuration for plan
*/
public function getTrialConfig($planId): ?array
{
$plan = $this->plans->firstWhere('id', $planId);
if (! $plan || ! $plan->hasTrial()) {
return null;
}
$config = $plan->getTrialConfig();
return [
'duration_days' => $config->trial_duration_days,
'requires_payment_method' => $config->trial_requires_payment_method,
'auto_converts' => $config->trial_auto_converts,
];
}
/**
* Get billing cycle display text
*/
public function getBillingCycleDisplay($plan): string
{
return $plan->getBillingCycleDisplay();
}
public function render(): Factory|View
{
return view('livewire.dashboard.pricing');

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\PlanUsage;
use App\Models\TrialExtension;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class SubscriptionDashboard extends Component
{
public $subscription;
public $plan;
public $usageData;
public $upgradePaths;
public $trialExtensions;
public $loading = true;
public function mount(): void
{
$this->loadSubscriptionData();
}
public function loadSubscriptionData(): void
{
$this->loading = true;
$user = Auth::user();
$this->subscription = $user->subscription('default');
if ($this->subscription) {
$this->plan = $this->subscription->plan->load([
'planFeatureLimits.planFeature',
'trialConfiguration',
'planTier',
]);
$this->loadUsageData();
$this->loadUpgradePaths();
$this->loadTrialExtensions();
}
$this->loading = false;
}
private function loadUsageData(): void
{
if (! $this->subscription) {
return;
}
$this->usageData = PlanUsage::where('user_id', Auth::id())
->where('plan_id', $this->subscription->plan_id)
->with('planFeature')
->get()
->map(function ($usage) {
$limit = $this->plan->planFeatureLimits
->where('plan_feature_id', $usage->plan_feature_id)
->first();
return [
'feature' => $usage->planFeature,
'usage' => $usage->usage_value,
'period' => $usage->period_type,
'limit' => $limit,
'remaining' => $limit ? $limit->getRemainingUsage($usage->usage_value, $this->subscription->onTrial()) : 0,
'percentage_used' => $limit && $limit->limit_value ?
min(100, ($usage->usage_value / $limit->limit_value) * 100) : 0,
];
});
}
private function loadUpgradePaths(): void
{
if (! $this->subscription) {
return;
}
$this->upgradePaths = $this->plan->getUpgradePath();
}
private function loadTrialExtensions(): void
{
if (! $this->subscription) {
return;
}
$this->trialExtensions = TrialExtension::where('subscription_id', $this->subscription->id)
->orderBy('created_at', 'desc')
->get();
}
public function requestTrialExtension(): void
{
if (! $this->subscription || ! $this->subscription->onTrial()) {
session()->flash('error', 'You can only request extensions for active trials.');
return;
}
$trialConfig = $this->plan->getTrialConfig();
if (! $trialConfig) {
session()->flash('error', 'This plan does not support trial extensions.');
return;
}
$currentExtensions = $this->trialExtensions->count();
if (! $trialConfig->canExtendTrial($currentExtensions)) {
session()->flash('error', 'You have reached the maximum number of trial extensions.');
return;
}
// Create trial extension request
TrialExtension::create([
'subscription_id' => $this->subscription->id,
'user_id' => Auth::id(),
'original_ends_at' => $this->subscription->trial_ends_at,
'extension_days' => $trialConfig->trial_duration_days,
'status' => 'pending',
'reason' => 'User requested extension via dashboard',
]);
session()->flash('success', 'Trial extension request submitted successfully.');
$this->loadTrialExtensions();
}
public function cancelSubscription(): void
{
if (! $this->subscription) {
return;
}
$this->subscription->cancel();
session()->flash('success', 'Subscription cancelled successfully.');
$this->loadSubscriptionData();
}
public function pauseSubscription(): void
{
if (! $this->subscription) {
return;
}
// Implement pause logic based on your business requirements
session()->flash('info', 'Pause functionality coming soon.');
}
public function resumeSubscription(): void
{
if (! $this->subscription) {
return;
}
$this->subscription->resume();
session()->flash('success', 'Subscription resumed successfully.');
$this->loadSubscriptionData();
}
public function getSubscriptionStatus(): string
{
if (! $this->subscription) {
return 'No Subscription';
}
return match ($this->subscription->status) {
'active' => 'Active',
'trialing' => 'Trial',
'cancelled' => 'Cancelled',
'past_due' => 'Past Due',
'unpaid' => 'Unpaid',
default => 'Unknown',
};
}
public function getSubscriptionStatusColor(): string
{
if (! $this->subscription) {
return 'gray';
}
return match ($this->subscription->status) {
'active' => 'green',
'trialing' => 'blue',
'cancelled' => 'red',
'past_due' => 'yellow',
'unpaid' => 'red',
default => 'gray',
};
}
public function getDaysRemaining(): int
{
if (! $this->subscription) {
return 0;
}
$endsAt = $this->subscription->trial_ends_at ?? $this->subscription->ends_at;
return $endsAt ? max(0, $endsAt->diffInDays(now())) : 0;
}
public function getNextBillingDate(): string
{
if (! $this->subscription || $this->subscription->cancelled()) {
return 'N/A';
}
return $this->subscription->ends_at?->format('M j, Y') ?? 'N/A';
}
public function getUsagePercentage($usageData): int
{
return (int) round($usageData['percentage_used']);
}
public function getUsageColor($percentage): string
{
return match (true) {
$percentage >= 90 => 'red',
$percentage >= 75 => 'yellow',
$percentage >= 50 => 'blue',
default => 'green',
};
}
public function render()
{
return view('livewire.dashboard.subscription-dashboard');
}
}

View File

@@ -19,6 +19,11 @@ class PlanUsage extends Model
'period_start',
'period_end',
'metadata',
'subscription_id',
'period_type',
'created_at',
'usage_value',
'updated_at',
];
protected $casts = [

View File

@@ -17,6 +17,8 @@ class TrialExtension extends Model
'granted_at',
'granted_by_admin_id',
'metadata',
'original_ends_at',
'status',
];
protected $casts = [