mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 00:14:48 +00:00
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:
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in new issue
Block a user