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.
This commit is contained in:
Andras Bacsai committed 2026-03-03 12:28:16 +01:00
1 parent 76ae720c36
commit d3b8d70f08
9 files changed
+423 -99

No files matched your search

@@ -8,6 +8,10 @@ use Stripe\StripeClient;
class UpdateSubscriptionQuantity
{
public const int MAX_SERVER_LIMIT = 100;
public const int MIN_SERVER_LIMIT = 2;
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
@@ -60,6 +64,7 @@ class UpdateSubscriptionQuantity
$taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
}
}
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
$taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
}
@@ -110,8 +115,8 @@ class UpdateSubscriptionQuantity
*/
public function execute(Team $team, int $quantity): array
{
if ($quantity < 2) {
return ['success' => false, 'error' => 'Minimum server limit is 2.'];
if ($quantity < self::MIN_SERVER_LIMIT) {
return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
}
$subscription = $team->subscription;
+7
View File
@@ -91,6 +91,13 @@ class TrustHosts extends Middleware
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
// Always trust loopback addresses so local access works even when FQDN is configured
foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) {
if (! in_array($localHost, $trustedHosts, true)) {
$trustedHosts[] = $localHost;
}
}
return array_filter($trustedHosts);
}
}
+22
View File
@@ -25,4 +25,26 @@ class TrustProxies extends Middleware
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Handle the request.
*
* Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed),
* the Secure cookie flag is auto-enabled when the request is over HTTPS.
* This ensures session cookies are correctly marked Secure when behind an HTTPS
* reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE
* is not explicitly set in .env.
*/
public function handle($request, \Closure $next)
{
return parent::handle($request, function ($request) use ($next) {
// At this point proxy headers have been applied to the request,
// so $request->secure() correctly reflects the actual protocol.
if ($request->secure() && config('session.secure') === null) {
config(['session.secure' => true]);
}
return $next($request);
});
}
}
+49
View File
@@ -5,6 +5,7 @@ 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;
@@ -14,6 +15,14 @@ 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;
@@ -25,6 +34,46 @@ class Actions extends Component
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
+14
View File
@@ -20,6 +20,20 @@ class Subscription extends Model
return $this->belongsTo(Team::class);
}
public function billingInterval(): string
{
if ($this->stripe_plan_id) {
$configKey = collect(config('subscription'))
->search($this->stripe_plan_id);
if ($configKey && str($configKey)->contains('yearly')) {
return 'yearly';
}
}
return 'monthly';
}
public function type()
{
if (isStripe()) {