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(), 'missingRemote' => $this->plans->missingRemotePriceIds(), '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('academy'); } 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)) { // If the user already has an Academy subscription, allow an in-place upgrade // (e.g. Creator -> Pro) by swapping the subscription to the requested price. $subscription = $this->academySubscription($user); $currentPlan = $this->activePlan($user); // If current plan exists and the requested plan ranks higher, perform swap. if ($currentPlan !== null && ($this->planRank((string) $plan['tier']) > $this->planRank((string) $currentPlan['tier']))) { try { if ($subscription instanceof Subscription) { $subscription->swap((string) $plan['stripe_price_id']); } return \redirect()->route('academy.billing.account')->with('success', 'Subscription upgraded — your new plan is active.'); } catch (\Throwable $e) { $context = [ 'user_id' => $user->id ?? null, 'user_email' => $user->email ?? null, 'stripe_id' => $user->stripe_id ?? null, 'route' => 'academy.billing.checkout', 'attempt' => 'swap_subscription', 'plan_key' => $plan['key'] ?? null, 'plan_price_id' => $plan['stripe_price_id'] ?? null, 'request_ip' => $request->ip(), 'user_agent' => $request->header('User-Agent'), 'exception_class' => \get_class($e), 'exception_message' => $e->getMessage(), 'exception_code' => $e->getCode(), 'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, ]; if (method_exists($e, 'getStripeCode')) { $context['stripe_code'] = $e->getStripeCode(); } Log::error('Academy billing: failed to swap subscription for upgrade', $context); return $this->checkoutErrorResponse($request, $e); } } 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 (InvalidRequestException $e) { // Stripe returned a request error (e.g. missing/deleted customer). Try to recover once by // clearing stored `stripe_id`, recreating the customer in Stripe and retrying the checkout. if (str_contains($e->getMessage(), 'No such customer')) { try { $user->forceFill(['stripe_id' => null])->save(); // Create a fresh Stripe customer and persist the id if (method_exists($user, 'createAsStripeCustomer')) { $user->createAsStripeCustomer(); } else { // fallback to createOrGet behavior $user->createOrGetStripeCustomer(); } 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 $inner) { $context = [ 'user_id' => $user->id ?? null, 'user_email' => $user->email ?? null, 'stripe_id' => $user->stripe_id ?? null, 'route' => 'academy.billing.checkout', 'attempt' => 'recreate_customer_and_checkout', 'plan_key' => $plan['key'] ?? null, 'plan_price_id' => $plan['stripe_price_id'] ?? null, 'request_ip' => $request->ip(), 'user_agent' => $request->header('User-Agent'), 'exception_class' => \get_class($inner), 'exception_message' => $inner->getMessage(), 'exception_code' => $inner->getCode(), 'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null, ]; if (method_exists($inner, 'getStripeCode')) { $context['stripe_code'] = $inner->getStripeCode(); } Log::error('Academy billing: failed to recover Stripe customer and start checkout', $context); return $this->checkoutErrorResponse($request, $inner); } } // Not a recoverable customer-missing error; rethrow to be handled below throw $e; } catch (\Throwable $exception) { $context = [ 'user_id' => $user->id ?? null, 'user_email' => $user->email ?? null, 'stripe_id' => $user->stripe_id ?? null, 'route' => 'academy.billing.checkout', 'attempt' => 'start_checkout', 'plan_key' => $plan['key'] ?? null, 'plan_price_id' => $plan['stripe_price_id'] ?? null, 'request_ip' => $request->ip(), 'user_agent' => $request->header('User-Agent'), 'exception_class' => \get_class($exception), 'exception_message' => $exception->getMessage(), 'exception_code' => $exception->getCode(), 'exception_trace' => \method_exists($exception, 'getTraceAsString') ? $exception->getTraceAsString() : null, ]; if (method_exists($exception, 'getStripeCode')) { $context['stripe_code'] = $exception->getStripeCode(); } Log::error('Academy billing: unexpected error starting checkout', $context); 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.'); } try { return $user->redirectToBillingPortal(\route('academy.billing.account')); } catch (\Exception $e) { // If the Stripe customer was deleted or invalid, attempt a recovery similar to checkout. if ($e instanceof \Stripe\Exception\InvalidRequestException && str_contains($e->getMessage(), 'No such customer')) { try { $user->forceFill(['stripe_id' => null])->save(); if (method_exists($user, 'createAsStripeCustomer')) { $user->createAsStripeCustomer(); } else { $user->createOrGetStripeCustomer(); } return $user->redirectToBillingPortal(\route('academy.billing.account')); } catch (\Throwable $inner) { $context = [ 'user_id' => $user->id ?? null, 'user_email' => $user->email ?? null, 'stripe_id' => $user->stripe_id ?? null, 'route' => 'academy.billing.portal', 'attempt' => 'recreate_customer_and_redirect', 'request_ip' => $request->ip(), 'user_agent' => $request->header('User-Agent'), 'exception_class' => \get_class($inner), 'exception_message' => $inner->getMessage(), 'exception_code' => $inner->getCode(), 'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null, ]; if (method_exists($inner, 'getStripeCode')) { $context['stripe_code'] = $inner->getStripeCode(); } Log::error('Academy billing: failed to recover Stripe customer and open billing portal', $context); return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.'); } } $context = [ 'user_id' => $user->id ?? null, 'user_email' => $user->email ?? null, 'stripe_id' => $user->stripe_id ?? null, 'route' => 'academy.billing.portal', 'attempt' => 'redirect_to_portal', 'request_ip' => $request->ip(), 'user_agent' => $request->header('User-Agent'), 'exception_class' => \get_class($e), 'exception_message' => $e->getMessage(), 'exception_code' => $e->getCode(), 'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, ]; if (method_exists($e, 'getStripeCode')) { $context['stripe_code'] = $e->getStripeCode(); } Log::error('Academy billing: could not open Stripe billing portal', $context); return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.'); } } 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'), 'reportIssue' => $user ? \route('academy.billing.report_issue') : null, ], 'sessionId' => $request->query('session_id'), ])->rootView('academy'); } 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('academy'); } public function reportIssue(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse { /** @var User|null $user */ $user = $request->user(); if (! $user instanceof User) { return redirect()->route('login'); } $validated = $request->validate([ 'message' => ['nullable', 'string', 'max:2000'], 'session_id' => ['nullable', 'string'], 'issue_type' => ['nullable', 'string', 'in:billing,payment,upgrade,downgrade,cancel,access,other'], 'contact_email' => ['nullable', 'email:rfc', 'max:255'], ]); $payload = [ 'id' => (string) Str::uuid(), 'submitted_at' => now()->toISOString(), 'ip' => $request->ip(), 'user_agent' => $request->userAgent(), 'data' => [ 'topic' => 'contact', 'name' => (string) ($user->name ?: $user->username ?: 'Academy billing user'), 'email' => (string) ($validated['contact_email'] ?? $user->email), 'message' => $validated['message'] ?? null, 'issue_type' => $validated['issue_type'] ?? 'billing', 'session_id' => $validated['session_id'] ?? $request->query('session_id'), 'source' => 'academy_billing', 'user_id' => (string) $user->id, 'account_email' => (string) $user->email, 'current_url' => $request->fullUrl(), ], ]; try { try { Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } catch (\Throwable $e) { // best-effort store; do not fail the user when file storage is unavailable } $application = null; try { $application = StaffApplication::create([ 'id' => $payload['id'], 'topic' => 'contact', 'name' => $payload['data']['name'], 'email' => $payload['data']['email'], 'role' => 'academy_billing_support', 'portfolio' => null, 'message' => $payload['data']['message'], 'payload' => $payload, 'ip' => $payload['ip'], 'user_agent' => $payload['user_agent'], ]); } catch (\Throwable $e) { // ignore DB errors and fall back to a lightweight model for mail } $to = config('mail.from.address'); if ($to) { if (! $application) { $application = new StaffApplication([ 'topic' => 'contact', 'name' => $payload['data']['name'], 'email' => $payload['data']['email'], 'role' => 'academy_billing_support', 'message' => $payload['data']['message'], 'payload' => $payload, 'ip' => $payload['ip'], 'user_agent' => $payload['user_agent'], ]); $application->id = $payload['id']; $application->created_at = now(); } Mail::to($to)->send(new AcademyAccessIssue( $user, $payload['data']['message'] ?? null, $payload['data']['session_id'] ?? null, $payload['data']['issue_type'] ?? null, $payload['data']['email'] ?? null, )); } return redirect()->back()->with('success', 'Support request sent — we will verify and activate your access shortly.'); } catch (\Throwable $e) { report($e); return redirect()->back()->with('error', 'Could not send the support request. Please try again later or email academy@skinbase.org.'); } } 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'), 'checkout' => \route('academy.billing.checkout'), 'reportIssue' => \route('academy.billing.report_issue'), ], ])->rootView('academy'); } /** * @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'], 'remote_price_exists' => $plan['remote_price_exists'] ?? false, ]] : []; 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); } }