Files
coolify/app/Livewire/Subscription/Actions.php
T
Andras Bacsai 3987f97a8e fix(subscription): require team admin for billing actions
Subscription actions ran on the session team without a role check. A user
who switched the session to a team where they are only a member could
cancel, refund, or change that team's subscription.

- Lock the team id at mount and require admin or owner of that team
- Check the locked team and the role again in every billing action
- Reject actions after the session switched to another team
2026-10-05 17:04:05 +02:00

345 lines
12 KiB
PHP

<?php
namespace App\Livewire\Subscription;
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
use App\Actions\Stripe\RefundSubscription;
use App\Actions\Stripe\ResumeSubscription;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Actions\User\DeleteUserAccount;
use App\Models\Team;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Stripe\StripeClient;
class Actions extends Component
{
#[Locked]
public ?int $teamId = null;
public $server_limits = 0;
public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT;
public ?array $pricePreview = null;
public bool $isRefundEligible = false;
public int $refundDaysRemaining = 0;
public bool $refundCheckLoading = true;
public bool $refundAlreadyUsed = false;
public bool $refundLatestPayment = false;
public bool $deleteAccount = false;
public bool $canDeleteAccount = false;
public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null;
public function mount(): void
{
$team = currentTeam();
abort_unless($team && auth()->user()->isAdminOfTeam($team->id), 403);
$this->teamId = $team->id;
$this->server_limits = Team::serverLimit();
$this->quantity = (int) $this->server_limits;
$this->billingInterval = $team->subscription?->billingInterval() ?? 'monthly';
$this->canDeleteAccount = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true) === [];
}
public function loadPricePreview(int $quantity): void
{
$team = $this->authorizedTeam();
$this->quantity = $quantity;
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview($team, $quantity);
$this->pricePreview = $result['success'] ? $result['preview'] : null;
}
// Password validation is intentionally skipped for quantity updates.
// Unlike refunds/cancellations, changing the server limit is a
// non-destructive, reversible billing adjustment (prorated by Stripe).
public function updateQuantity(string $password = ''): bool
{
$team = $this->authorizedTeam();
if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) {
$this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.');
$this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT;
return true;
}
if ($this->quantity === (int) $this->server_limits) {
return true;
}
$previousServerLimit = (int) $this->server_limits;
$result = (new UpdateSubscriptionQuantity)->execute($team, $this->quantity);
if ($result['success']) {
$this->auditSubscriptionChange('quantity_updated', [
'from_server_limit' => $previousServerLimit,
'to_server_limit' => $this->quantity,
]);
$this->server_limits = $this->quantity;
$this->pricePreview = null;
$this->dispatch('success', 'Server limit updated to '.$this->quantity.'.');
return true;
}
$this->dispatch('error', $result['error'] ?? 'Failed to update server limit.');
$this->quantity = (int) $this->server_limits;
return true;
}
public function loadRefundEligibility(): void
{
$this->authorizedTeam();
$this->checkRefundEligibility();
$this->refundCheckLoading = false;
}
public function stripeCustomerPortal(): void
{
$session = getStripeCustomerPortalSession($this->authorizedTeam());
redirect($session->url);
}
public function refundSubscription(string $password): bool|string
{
$team = $this->authorizedTeam();
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$result = app(RefundSubscription::class)->execute($team);
if ($result['success']) {
$this->auditSubscriptionChange('refunded');
$this->dispatch('success', 'Subscription refunded successfully.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
}
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
public function cancelImmediately(string $password, array $selectedActions = []): bool|string
{
$team = $this->authorizedTeam();
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$deleteAccount = in_array('deleteAccount', $selectedActions, true);
if ($deleteAccount && ($blockers = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true))) {
return implode(' ', $blockers);
}
$refund = in_array('refundLatestPayment', $selectedActions, true);
if (! $this->cancelSubscriptionNow($team, $refund)) {
$this->dispatch('error', 'Something went wrong with the '.($refund ? 'refund' : 'cancellation').'. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
if ($deleteAccount) {
return $this->deleteAccountAfterCancellation();
}
$this->dispatch('success', $refund ? 'Subscription refunded and cancelled successfully.' : 'Subscription cancelled successfully.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
}
public function cancelAtPeriodEnd(string $password): bool|string
{
$team = $this->authorizedTeam();
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$result = (new CancelSubscriptionAtPeriodEnd)->execute($team);
if ($result['success']) {
$this->auditSubscriptionChange('cancellation_scheduled', ['cancel_at_period_end' => true]);
$this->dispatch('success', 'Subscription will be cancelled at the end of the billing period.');
return true;
}
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
public function resumeSubscription(): bool
{
$team = $this->authorizedTeam();
$result = (new ResumeSubscription)->execute($team);
if ($result['success']) {
$this->auditSubscriptionChange('resumed');
$this->dispatch('success', 'Subscription resumed successfully.');
return true;
}
$this->dispatch('error', 'Something went wrong resuming the subscription. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
private function cancelSubscriptionNow(Team $team, bool $refund): bool
{
if ($refund) {
// Eligibility is re-validated server-side inside RefundSubscription::execute()
$result = app(RefundSubscription::class)->execute($team);
if ($result['success']) {
$this->auditSubscriptionChange('refunded');
}
return $result['success'];
}
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
return false;
}
// subscriptionEnded() clears the ID on this model, so keep it for the log.
$subscriptionId = $subscription->stripe_subscription_id;
try {
$stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscriptionId);
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'Cancelled immediately by user',
'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(),
]);
$team->subscriptionEnded();
$this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]);
\Log::info("Subscription {$subscriptionId} cancelled immediately for team {$team->name}");
return true;
} catch (\Exception $e) {
\Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage());
return false;
}
}
private function deleteAccountAfterCancellation(): bool
{
try {
app(DeleteUserAccount::class)->handle(Auth::user(), removeTeamResources: true);
} catch (\Throwable $e) {
report($e);
$this->dispatch('error', 'Your subscription was cancelled, but your account could not be deleted. Delete it from your profile page or <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
}
auditLog('ui.user.account_deleted', ['resource' => 'user']);
Auth::guard('web')->logout();
session()->invalidate();
session()->regenerateToken();
$this->redirect(route('login'));
return true;
}
/**
* Resolve the team locked at mount, rejecting the request when the session
* switched to another team or the user is no longer an admin of that team.
*/
private function authorizedTeam(): Team
{
$team = currentTeam();
abort_unless(
$this->teamId !== null
&& $team?->id === $this->teamId
&& auth()->user()->isAdminOfTeam($this->teamId),
403
);
return $team;
}
/**
* Record a user-triggered subscription change for the current team.
*
* @param array<string, mixed> $context
*/
private function auditSubscriptionChange(string $action, array $context = []): void
{
$team = $this->authorizedTeam();
auditLog("ui.subscription.{$action}", [
'team_id' => $team?->id,
'resource' => 'subscription',
'subscription_id' => $team?->subscription?->id,
'team_name' => $team?->name,
...$context,
]);
}
private function checkRefundEligibility(): void
{
$team = $this->authorizedTeam();
if (! isCloud() || ! $team->subscription?->stripe_subscription_id) {
return;
}
try {
$this->refundAlreadyUsed = $team->subscription?->stripe_refunded_at !== null;
$result = (new RefundSubscription)->checkEligibility($team);
$this->isRefundEligible = $result['eligible'];
$this->refundDaysRemaining = $result['days_remaining'];
if ($result['current_period_end']) {
$this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y');
}
} catch (\Exception $e) {
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
}
}
}