Files
SkinbaseNova/app/Http/Controllers/Academy/AcademyBillingController.php
2026-06-09 13:16:01 +02:00

715 lines
30 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyBillingPlanService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
use Illuminate\Support\Facades\Log;
use App\Mail\AcademyAccessIssue;
use App\Models\StaffApplication;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyBillingController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyBillingPlanService $plans,
) {}
public function pricing(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$this->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<int, array<string, mixed>>
*/
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<string, mixed>|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);
}
}