plans->assertConfigured(); /** @var User|null $user */ $user = $request->user(); $canonical = \route('academy.pricing'); $seo = \app(SeoFactory::class) ->collectionPage( 'Skinbase AI Academy Pricing — Skinbase', 'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.', $canonical, ) ->toArray(); $seo['og_type'] = 'website'; $activePlan = $user instanceof User ? $this->activePlan($user) : null; return \Inertia\Inertia::render('Academy/Billing/Pricing', [ 'seo' => $seo, 'billingEnabled' => $this->plans->enabled(), 'currentTier' => $this->access->currentTier($user), 'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false, 'activePlanKey' => $activePlan['key'] ?? null, 'activePlanLabel' => $activePlan['label'] ?? null, 'catalog' => $this->catalog(), 'links' => [ 'login' => \route('login'), 'pricing' => \route('academy.pricing'), 'billingAccount' => $user ? \route('academy.billing.account') : null, 'checkout' => $user ? \route('academy.billing.checkout') : null, ], 'analytics' => [ 'enabled' => true, 'contentType' => AcademyAnalyticsContentType::UPGRADE, 'contentId' => null, 'eventUrl' => \route('academy.analytics.events.store'), 'pageName' => 'academy_billing_pricing', 'isPremium' => false, 'isGuest' => $user === null, 'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(), ], ])->rootView('collections'); } public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { \abort_unless((bool) \config('academy.enabled', true), 404); if (! $this->plans->enabled()) { return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.'); } $this->plans->assertConfigured(); $user = $request->user(); if (! $user instanceof User) { return \redirect()->route('login'); } if ($user->email_verified_at === null) { throw \Illuminate\Validation\ValidationException::withMessages([ 'plan' => 'Verify your email address before starting Academy billing.', ]); } $validated = $request->validate([ 'plan' => ['required', 'string'], ]); $plan = $this->plans->plan((string) $validated['plan']); if ($plan === null) { throw \Illuminate\Validation\ValidationException::withMessages([ 'plan' => 'Select a valid Academy billing plan.', ]); } if (! ($plan['configured'] ?? false)) { return $this->missingPriceIdResponse($request, (string) $plan['key']); } if (! ($plan['price_id_valid'] ?? false)) { return $this->invalidPriceIdResponse($request, (string) $plan['key']); } if ($this->access->hasActiveAcademySubscription($user)) { return \redirect()->route('academy.billing.portal'); } try { return $user ->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id']) ->withMetadata([ 'skinbase_module' => 'academy', 'user_id' => (string) $user->id, 'academy_plan' => (string) $plan['key'], 'academy_tier' => (string) $plan['tier'], ]) ->checkout([ 'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => \route('academy.billing.cancel'), 'allow_promotion_codes' => true, 'metadata' => [ 'skinbase_module' => 'academy', 'user_id' => (string) $user->id, 'academy_plan' => (string) $plan['key'], 'academy_tier' => (string) $plan['tier'], ], ]); } catch (\Throwable $exception) { \report($exception); return $this->checkoutErrorResponse($request, $exception); } } public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { $request->merge([ 'plan' => $this->plans->normalizePlanKey($plan), ]); return $this->checkout($request); } public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse { \abort_unless((bool) \config('academy.enabled', true), 404); \abort_unless($this->plans->enabled(), 404); /** @var User|null $user */ $user = $request->user(); if (! $user instanceof User || \blank($user->stripe_id)) { return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.'); } return $user->redirectToBillingPortal(\route('academy.billing.account')); } public function success(\Illuminate\Http\Request $request): \Inertia\Response { \abort_unless((bool) \config('academy.enabled', true), 404); /** @var User|null $user */ $user = $request->user(); $currentTier = $this->access->currentTier($user); return \Inertia\Inertia::render('Academy/Billing/Success', [ 'message' => 'Payment is being confirmed. Your access will update automatically.', 'currentTier' => $currentTier, 'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false, 'links' => [ 'pricing' => \route('academy.pricing'), 'account' => $user ? \route('academy.billing.account') : null, 'academy' => \route('academy.index'), ], 'sessionId' => $request->query('session_id'), ])->rootView('collections'); } public function cancel(): \Inertia\Response { \abort_unless((bool) \config('academy.enabled', true), 404); return \Inertia\Inertia::render('Academy/Billing/Cancel', [ 'message' => 'Checkout was canceled. No payment was made.', 'links' => [ 'pricing' => \route('academy.pricing'), 'academy' => \route('academy.index'), ], ])->rootView('collections'); } public function account(\Illuminate\Http\Request $request): \Inertia\Response { \abort_unless((bool) \config('academy.enabled', true), 404); \abort_unless($this->plans->enabled(), 404); /** @var User $user */ $user = $request->user(); $subscription = $this->academySubscription($user); $activePlan = $this->activePlan($user); return \Inertia\Inertia::render('Academy/Billing/Account', [ 'currentTier' => $this->access->currentTier($user), 'isSubscribed' => $this->access->hasActiveAcademySubscription($user), 'activePlan' => $activePlan ? [ 'key' => $activePlan['key'], 'label' => $activePlan['label'], 'price_display' => $activePlan['price_display'] ?? null, 'tier' => $activePlan['tier'], ] : null, 'subscription' => $subscription ? [ 'name' => $subscription->type, 'status' => $subscription->stripe_status, 'active' => $subscription->active(), 'onGracePeriod' => $subscription->onGracePeriod(), 'endsAt' => $subscription->ends_at?->toISOString(), 'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(), ] : null, 'links' => [ 'portal' => \route('academy.billing.portal'), 'pricing' => \route('academy.pricing'), 'academy' => \route('academy.index'), ], ])->rootView('collections'); } /** * @return array> */ private function catalog(): array { $definitions = [ 'creator' => [ 'name' => 'Creator', 'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.', 'badge' => 'Paid', 'featured' => false, 'features' => [ 'Creator lessons and walkthroughs', 'Full Creator prompt templates', 'Prompt save and reuse flows', 'Upgrade path into Pro later', ], ], 'pro' => [ 'name' => 'Pro', 'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.', 'badge' => 'Recommended', 'featured' => true, 'features' => [ 'Everything in Creator', 'Advanced Pro lessons and prompt systems', 'Priority access to future Academy premium features', 'Stripe billing portal for upgrades and invoices', ], ], ]; return collect($definitions) ->map(function (array $definition, string $tier): array { $plan = $this->plans->plan($tier.'_monthly'); $plans = $plan !== null ? [[ 'key' => $plan['key'], 'label' => $plan['label'], 'interval' => $plan['interval'], 'amount' => $plan['amount'], 'currency' => $plan['currency'], 'price_display' => $plan['price_display'], 'configured' => $plan['configured'], 'price_id_valid' => $plan['price_id_valid'], ]] : []; return [ 'tier' => $tier, 'name' => $definition['name'], 'description' => $definition['description'], 'badge' => $definition['badge'], 'featured' => $definition['featured'], 'features' => $definition['features'], 'plans' => $plans, ]; }) ->values() ->all(); } private function academySubscription(User $user): ?Subscription { $subscription = $user->subscription($this->plans->subscriptionName()); return $subscription instanceof Subscription ? $subscription->loadMissing('items') : null; } /** * @return array|null */ private function activePlan(User $user): ?array { $subscription = $this->academySubscription($user); if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) { return null; } $matchedPlan = null; foreach ($subscription->items as $item) { $priceId = trim((string) $item->stripe_price); if ($priceId === '') { continue; } $plan = $this->plans->planForPriceId($priceId); if ($plan === null) { continue; } if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) { $matchedPlan = $plan; } } if ($matchedPlan !== null) { return $matchedPlan; } $fallbackPriceId = trim((string) $subscription->stripe_price); return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null; } private function planRank(string $tier): int { return match (strtolower(trim($tier))) { 'admin' => 40, 'pro' => 30, 'creator' => 20, default => 10, }; } private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { $payload = [ 'ok' => false, 'code' => 'academy_payments_disabled', 'message' => $message, ]; if ($request->expectsJson()) { return \response()->json($payload, 423); } return \redirect()->route('academy.pricing')->with('error', $message); } private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { $message = 'The selected Academy plan is not configured yet. Please try again later.'; if ($request->expectsJson()) { return \response()->json([ 'ok' => false, 'code' => 'academy_billing_price_missing', 'message' => $message, 'plan' => $planKey, ], 422); } return \redirect()->route('academy.pricing')->with('error', $message); } private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { $message = 'The selected Academy plan is misconfigured. Please contact support before continuing.'; if ($request->expectsJson()) { return \response()->json([ 'ok' => false, 'code' => 'academy_billing_price_invalid', 'message' => $message, 'plan' => $planKey, ], 422); } return \redirect()->route('academy.pricing')->with('error', $message); } private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse { $message = 'Academy checkout could not be started right now.'; if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') { $message .= ' '.$exception->getMessage(); } if ($request->expectsJson()) { return \response()->json([ 'ok' => false, 'code' => 'academy_billing_checkout_failed', 'message' => $message, ], 422); } return \redirect()->route('academy.pricing')->with('error', $message); } }