Optimize academy
This commit is contained in:
@@ -12,7 +12,14 @@ 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(
|
||||
@@ -48,6 +55,7 @@ final class AcademyBillingController extends Controller
|
||||
'activePlanKey' => $activePlan['key'] ?? null,
|
||||
'activePlanLabel' => $activePlan['label'] ?? null,
|
||||
'catalog' => $this->catalog(),
|
||||
'missingRemote' => $this->plans->missingRemotePriceIds(),
|
||||
'links' => [
|
||||
'login' => \route('login'),
|
||||
'pricing' => \route('academy.pricing'),
|
||||
@@ -64,7 +72,7 @@ final class AcademyBillingController extends Controller
|
||||
'isGuest' => $user === null,
|
||||
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
|
||||
],
|
||||
])->rootView('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
|
||||
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
|
||||
@@ -110,6 +118,46 @@ final class AcademyBillingController extends Controller
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -133,8 +181,91 @@ final class AcademyBillingController extends Controller
|
||||
'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) {
|
||||
\report($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);
|
||||
}
|
||||
@@ -161,7 +292,68 @@ final class AcademyBillingController extends Controller
|
||||
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'));
|
||||
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
|
||||
@@ -180,9 +372,10 @@ final class AcademyBillingController extends Controller
|
||||
'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('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
|
||||
public function cancel(): \Inertia\Response
|
||||
@@ -195,8 +388,104 @@ final class AcademyBillingController extends Controller
|
||||
'pricing' => \route('academy.pricing'),
|
||||
'academy' => \route('academy.index'),
|
||||
],
|
||||
])->rootView('collections');
|
||||
])->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
|
||||
{
|
||||
@@ -230,8 +519,10 @@ final class AcademyBillingController extends Controller
|
||||
'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('collections');
|
||||
])->rootView('academy');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,6 +570,7 @@ final class AcademyBillingController extends Controller
|
||||
'price_display' => $plan['price_display'],
|
||||
'configured' => $plan['configured'],
|
||||
'price_id_valid' => $plan['price_id_valid'],
|
||||
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
|
||||
]] : [];
|
||||
|
||||
return [
|
||||
@@ -419,4 +711,4 @@ final class AcademyBillingController extends Controller
|
||||
|
||||
return \redirect()->route('academy.pricing')->with('error', $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user