coolify/app/Livewire/Subscription/Actions.php
Andras Bacsai d3b8d70f08 fix(subscription): harden quantity updates and proxy trust behavior
Centralize min/max server limits in Stripe quantity updates and wire them into
Livewire subscription actions with price preview/update handling.

Also improve host/proxy middleware behavior by trusting loopback hosts when FQDN
is set and auto-enabling secure session cookies for HTTPS requests behind
proxies when session.secure is unset.

Includes feature tests for loopback trust and secure cookie auto-detection.
2026-03-03 12:28:16 +01:00

205 lines
7.1 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\Models\Team;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Stripe\StripeClient;
class Actions extends Component
{
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 function mount(): void
{
$this->server_limits = Team::serverLimit();
$this->quantity = (int) $this->server_limits;
}
public function loadPricePreview(int $quantity): void
{
$this->quantity = $quantity;
$result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $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
{
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;
}
$result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity);
if ($result['success']) {
$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->checkRefundEligibility();
$this->refundCheckLoading = false;
}
public function stripeCustomerPortal(): void
{
$session = getStripeCustomerPortalSession(currentTeam());
redirect($session->url);
}
public function refundSubscription(string $password): bool|string
{
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$result = (new RefundSubscription)->execute(currentTeam());
if ($result['success']) {
$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): bool|string
{
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$team = currentTeam();
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
$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;
}
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
$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();
\Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}");
$this->dispatch('success', 'Subscription cancelled successfully.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
} catch (\Exception $e) {
\Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage());
$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 cancelAtPeriodEnd(string $password): bool|string
{
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
$result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam());
if ($result['success']) {
$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
{
$result = (new ResumeSubscription)->execute(currentTeam());
if ($result['success']) {
$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 checkRefundEligibility(): void
{
if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) {
return;
}
try {
$this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null;
$result = (new RefundSubscription)->checkEligibility(currentTeam());
$this->isRefundEligible = $result['eligible'];
$this->refundDaysRemaining = $result['days_remaining'];
} catch (\Exception $e) {
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
}
}
}