Two issues from review:
1. BillingController::checkout was setting plan_id immediately after
creating the Stripe Checkout session, before the user actually paid.
If the user abandoned checkout, the account ended up with a plan it
never paid for. Plan activation is now driven exclusively by the
customer.subscription.created webhook, which fires only after a
successful payment.
2. The 'if (\$account->wasChanged('plan_id')) { ... }' guards around
forgetPlanFeatureCache() were tautological — Eloquent's update()
already short-circuits when nothing changed, and Pennant forget()
is idempotent, so an extra cache clear when the plan didn't move
is harmless. Removing the guards keeps the listener and swap path
readable.
184 lines
5.9 KiB
PHP
184 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\App;
|
|
|
|
use App\Models\Account;
|
|
use App\Models\Plan;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
|
|
|
class BillingController extends Controller
|
|
{
|
|
public function subscribe(Request $request): Response|RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$account = $request->user()->account;
|
|
|
|
if ($account && $account->hasActiveSubscription()) {
|
|
return redirect()->route('app.billing.index');
|
|
}
|
|
|
|
return Inertia::render('billing/Subscribe', [
|
|
'plans' => Plan::active()->orderBy('sort')->get(),
|
|
'trialDays' => config('cashier.trial_days'),
|
|
]);
|
|
}
|
|
|
|
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$user = $request->user();
|
|
$account = $user->account;
|
|
|
|
abort_unless($user->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
|
|
|
$request->validate([
|
|
'price_id' => ['required', 'string'],
|
|
]);
|
|
|
|
$priceId = $request->input('price_id');
|
|
|
|
abort_unless(
|
|
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
|
|
422,
|
|
'Invalid price for this plan',
|
|
);
|
|
|
|
$account->createOrGetStripeCustomer([
|
|
'email' => $account->stripeEmail(),
|
|
'name' => $account->stripeName(),
|
|
]);
|
|
|
|
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
|
->allowPromotionCodes()
|
|
->trialDays(config('cashier.trial_days'));
|
|
|
|
$checkoutSession = $subscription->checkout([
|
|
'success_url' => route('app.billing.processing'),
|
|
'cancel_url' => route('app.subscribe'),
|
|
]);
|
|
|
|
return Inertia::location($checkoutSession->url);
|
|
}
|
|
|
|
public function processing(Request $request): Response|RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$account = $request->user()->account;
|
|
|
|
return Inertia::render('billing/Processing', [
|
|
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
|
|
]);
|
|
}
|
|
|
|
public function index(Request $request): Response|RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$account = $request->user()->account;
|
|
|
|
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
|
|
|
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
|
|
|
|
return Inertia::render('settings/account/Billing', [
|
|
'hasSubscription' => $account->subscribed(Account::SUBSCRIPTION_NAME),
|
|
'onTrial' => $subscription?->onTrial() ?? false,
|
|
'trialEndsAt' => $subscription?->trial_ends_at,
|
|
'subscription' => $subscription?->only([
|
|
'stripe_status',
|
|
'ends_at',
|
|
]),
|
|
'plan' => $account->plan,
|
|
'plans' => Plan::active()->orderBy('sort')->get(),
|
|
'invoices' => $account->invoices()->map(fn ($invoice) => [
|
|
'id' => $invoice->id,
|
|
'date' => $invoice->date(),
|
|
'total' => $invoice->total(),
|
|
'status' => $invoice->status,
|
|
'invoice_pdf' => $invoice->invoice_pdf,
|
|
]),
|
|
'defaultPaymentMethod' => $account->displayablePaymentMethod(),
|
|
]);
|
|
}
|
|
|
|
public function swap(Request $request, Plan $plan): RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$account = $request->user()->account;
|
|
|
|
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
|
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
|
|
|
|
$request->validate([
|
|
'price_id' => ['required', 'string'],
|
|
]);
|
|
|
|
$priceId = $request->input('price_id');
|
|
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
|
|
|
|
abort_unless(
|
|
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
|
|
422,
|
|
'Invalid price for this plan',
|
|
);
|
|
|
|
$currentPlan = $account->plan;
|
|
$isOnYearly = $currentPlan && $subscription->stripe_price === $currentPlan->stripe_yearly_price_id;
|
|
$isTargetMonthly = $priceId === $plan->stripe_monthly_price_id;
|
|
|
|
abort_if(
|
|
$isOnYearly && $isTargetMonthly,
|
|
422,
|
|
'Cannot downgrade from yearly to monthly billing.',
|
|
);
|
|
|
|
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
|
|
|
|
if ($authorization->denied()) {
|
|
return back()->with('flash.error', $authorization->message());
|
|
}
|
|
|
|
$subscription->swap($priceId);
|
|
$account->update(['plan_id' => $plan->id]);
|
|
$account->forgetPlanFeatureCache();
|
|
|
|
return redirect()->route('app.billing.index')
|
|
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));
|
|
}
|
|
|
|
public function portal(Request $request): RedirectResponse
|
|
{
|
|
if (config('trypost.self_hosted')) {
|
|
return redirect()->route('app.calendar');
|
|
}
|
|
|
|
$account = $request->user()->account;
|
|
|
|
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
|
|
|
return $account->redirectToBillingPortal(
|
|
route('app.billing.index')
|
|
);
|
|
}
|
|
}
|