diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index 17d1409..adf1bf8 100644 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -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'; + } } diff --git a/app/Http/Middleware/EnforceFeatureLimits.php b/app/Http/Middleware/EnforceFeatureLimits.php new file mode 100644 index 0000000..2be3834 --- /dev/null +++ b/app/Http/Middleware/EnforceFeatureLimits.php @@ -0,0 +1,289 @@ +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}. + Upgrade your plan 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'); + } +} diff --git a/app/Livewire/Dashboard/Pricing.php b/app/Livewire/Dashboard/Pricing.php index 57b819f..7cc1f3f 100644 --- a/app/Livewire/Dashboard/Pricing.php +++ b/app/Livewire/Dashboard/Pricing.php @@ -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'); diff --git a/app/Livewire/Dashboard/SubscriptionDashboard.php b/app/Livewire/Dashboard/SubscriptionDashboard.php new file mode 100644 index 0000000..995b8c9 --- /dev/null +++ b/app/Livewire/Dashboard/SubscriptionDashboard.php @@ -0,0 +1,237 @@ +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'); + } +} diff --git a/app/Models/PlanUsage.php b/app/Models/PlanUsage.php index 791c052..242d1d8 100644 --- a/app/Models/PlanUsage.php +++ b/app/Models/PlanUsage.php @@ -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 = [ diff --git a/app/Models/TrialExtension.php b/app/Models/TrialExtension.php index d92290f..f23fb0a 100644 --- a/app/Models/TrialExtension.php +++ b/app/Models/TrialExtension.php @@ -17,6 +17,8 @@ class TrialExtension extends Model 'granted_at', 'granted_by_admin_id', 'metadata', + 'original_ends_at', + 'status', ]; protected $casts = [ diff --git a/resources/views/livewire/dashboard/partials/plan-card.blade.php b/resources/views/livewire/dashboard/partials/plan-card.blade.php new file mode 100644 index 0000000..a8fec54 --- /dev/null +++ b/resources/views/livewire/dashboard/partials/plan-card.blade.php @@ -0,0 +1,147 @@ +
+ + @if($plan->planTier && $plan->planTier->sort_order > 1) +
+ Most Popular +
+ @endif + +
+

{{ $plan->name }}

+ @if($plan->planTier) +

{{ $plan->planTier->name }} Tier

+ @endif + +

+ ${{ number_format($plan->price, 2) }} + /{{ $billingCycle }} +

+ + @if($plan->description) +

{{ $plan->description }}

+ @endif +
+ + + @if($features) + + @else +
+

+ No features configured for this plan. +

+
+ @endif + + + @if($hasTrial && $trialConfig) +
+
+ + {{ $trialConfig['duration_days'] }}-day free trial +
+ @if($trialConfig['requires_payment_method']) +

Payment method required

+ @endif +
+ @endif + + +
+ @foreach($providers as $provider) + @if($provider === 'stripe') + @if($hasTrial && $trialConfig) + + Start Free Trial + + @endif + + Pay with Card + + @elseif($provider === 'lemon_squeezy') + + Pay with Lemon Squeezy + + @elseif($provider === 'polar') + + Pay with Polar.sh + + @elseif($provider === 'oxapay') + + Pay with OxaPay + + @elseif($provider === 'crypto') + + Pay with Crypto + + @elseif($provider === 'activation_key') + + Activate via Activation Key + + @endif + @endforeach +
+
diff --git a/resources/views/livewire/dashboard/pricing.blade.php b/resources/views/livewire/dashboard/pricing.blade.php index 1a030ad..c3d0335 100644 --- a/resources/views/livewire/dashboard/pricing.blade.php +++ b/resources/views/livewire/dashboard/pricing.blade.php @@ -1,116 +1,106 @@ -
-{{-- --}} +
-

Purchase Subscription

+

Choose Your Plan

-
+ + @if($planTiers && $planTiers->count() > 1) +
+
+ + @foreach($planTiers as $tier) + + @endforeach +
+
+ @endif + + +
@if(isset($plans)) @foreach($plans as $plan) -
-
-

{{ $plan['name'] }} @if(!$plan['monthly_billing']) - 2 Months Free - @endif

+ @php + $providers = $this->getPlanProviders($plan->id); + $features = $this->getPlanFeatures($plan->id); + $hasTrial = $this->planHasTrial($plan->id); + $trialConfig = $this->planHasTrial($plan->id) ? $this->getTrialConfig($plan->id) : null; + $billingCycle = $this->getBillingCycleDisplay($plan); + $isPopularTier = $plan->planTier && $plan->planTier->sort_order > 1; + @endphp -

- ${{ $plan['price'] }} - /{{ $plan['monthly_billing'] ? 'month' : 'year' }} -

-
- -
    - - @if($plan['details']) - @forelse ($plan['details'] as $key => $value) - @if ($value) -
  • - @if($value == "true") - @else - @endif - {{ $key }} -
  • - @endif - @empty - @endforelse - @endif -
- - @if($plan['accept_stripe'] && $plan['pricing_id'] !== null) - - Pay with card - - @endif - @if($plan['accept_shoppy'] && $plan['shoppy_product_id'] !== null) - - Pay with crypto - - @endif - @if($plan['accept_oxapay'] && $plan['oxapay_link'] !== null) - - Pay with crypto - - @endif -
+ @include('livewire.dashboard.partials.plan-card', [ + 'plan' => $plan, + 'providers' => $providers, + 'features' => $features, + 'hasTrial' => $hasTrial, + 'trialConfig' => $trialConfig, + 'billingCycle' => $billingCycle, + 'isPopularTier' => $isPopularTier + ]) @endforeach @endif -
- - -
+ +
-
-

Have an Activation Key?

+
+

Have an Activation Key?

-
- - -
-
- Redeem your activation key, purchased with Pay with Crypto option. -
-
- @error('activation_key') -
- {{ $message }} +
+
+ + +
+
+ Redeem your activation key for instant access to premium features.
- @enderror - - @if (session()->has('success')) -
- {{ session('success') }} -
- @endif - @if (session()->has('error')) -
- {{ session('error') }} + +
+ @error('activation_key') +
+ {{ $message }}
- @endif + @enderror + + @if (session()->has('success')) +
+ {{ session('success') }} +
+ @endif + + @if (session()->has('error')) +
+ {{ session('error') }} +
+ @endif +
-
diff --git a/resources/views/livewire/dashboard/subscription-dashboard.blade.php b/resources/views/livewire/dashboard/subscription-dashboard.blade.php new file mode 100644 index 0000000..5d98caf --- /dev/null +++ b/resources/views/livewire/dashboard/subscription-dashboard.blade.php @@ -0,0 +1,230 @@ +
+ +
+

Subscription Dashboard

+

Manage your subscription and track usage

+
+ + @if($loading) +
+
+
+ @else + + @if($subscription) +
+
+
+
+

+ {{ $plan->name }} +

+ + {{ $this->getSubscriptionStatus() }} + +
+ +
+
+

Monthly Price

+

+ ${{ number_format($plan->price, 2) }} +

+
+
+

Days Remaining

+

+ {{ $this->getDaysRemaining() }} days +

+
+
+

Next Billing

+

+ {{ $this->getNextBillingDate() }} +

+
+
+ + +
+ @if($subscription->onTrial()) + + Request Trial Extension + Processing... + + @endif + + @if($subscription->cancelled()) + + Resume Subscription + Processing... + + @else + + Pause Subscription + + + Cancel Subscription + Processing... + + @endif +
+
+
+
+ + + @if($usageData && $usageData->count() > 0) +
+

Usage Tracking

+ +
+ @foreach($usageData as $usage) + @php + $percentage = $this->getUsagePercentage($usage); + $color = $this->getUsageColor($percentage); + @endphp +
+
+

+ {{ $usage['feature']->display_name }} +

+ + {{ $percentage }}% + +
+ +
+
+
+
+
+ +
+ Used: {{ $usage['usage'] }} + Limit: {{ $usage['limit']->limit_value ?? 'Unlimited' }} +
+ + @if($usage['remaining'] > 0) +

+ {{ $usage['remaining'] }} remaining +

+ @endif +
+ @endforeach +
+
+ @endif + + + @if($subscription->onTrial() && $trialExtensions && $trialExtensions->count() > 0) +
+

Trial Extensions

+ +
+ @foreach($trialExtensions as $extension) +
+
+

+ {{ $extension->extension_days }} days extension +

+

+ Requested {{ $extension->created_at->format('M j, Y') }} +

+
+ + {{ ucfirst($extension->status) }} + +
+ @endforeach +
+
+ @endif + + + @if($upgradePaths && count($upgradePaths) > 0) +
+

Available Upgrades

+ +
+ @foreach($upgradePaths as $upgradePlan) +
+

+ {{ $upgradePlan['name'] }} +

+

+ ${{ number_format($upgradePlan['price'], 2) }} + + /{{ $upgradePlan->getBillingCycleDisplay() }} + +

+ + Upgrade Now + +
+ @endforeach +
+
+ @endif + + @else + +
+ +

+ No Active Subscription +

+

+ You don't have an active subscription. Choose a plan to get started! +

+ + View Plans + +
+ @endif + + + @if(session()->has('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session()->has('error')) +
+

{{ session('error') }}

+
+ @endif + + @if(session()->has('info')) +
+

{{ session('info') }}

+
+ @endif + @endif +