-
- Payment frequency
-
-
- Monthly
-
-
-
- Annually (save ~20%)
-
-
+
+ {{-- Frequency Toggle --}}
+
+
+ Payment frequency
+
+
+ Monthly
+
+
+
+ Annually (save ~20%)
+
+
+
+
+
+ {{-- Plan Header + Pricing --}}
+
Pay-as-you-go
+
Dynamic pricing based on the number of servers you connect.
+
+
+
+ $5
+ / mo base
+
+
+ $4
+ / mo base
+
-
-
-
-
Pay-as-you-go
-
- Dynamic pricing based on the number of servers you connect.
-
-
-
- $5
- base price
-
+
+
+ + $3 per additional server, billed monthly (+VAT)
+
+
+ + $2.7 per additional server, billed annually (+VAT)
+
+
-
- $4
- base price
-
-
-
-
- $3
- per additional servers billed monthly (+VAT)
-
+ {{-- Subscribe Button --}}
+
+
+ Subscribe
+
+
+ Subscribe
+
+
-
- $2.7
- per additional servers billed annually (+VAT)
-
-
-
-
-
-
-
+ {{-- Features --}}
+
+
+
+
+
+
+ Connect unlimited servers
+
+
+
+
+
+ Deploy unlimited applications per server
+
+
+
+
+
+ Free email notifications
+
+
+
+
+
+ Support by email
+
+
+
+
+
+
+ + All Upcoming Features
+
+
+
-
-
- You need to bring your own servers from any cloud provider (such as
Hetzner , DigitalOcean, AWS,
- etc.)
-
-
-
-
-
-
- Subscribe
-
-
- Subscribe
-
-
-
-
-
-
-
- Connect
- unlimited servers
-
-
-
-
-
- Deploy
- unlimited applications per server
-
-
-
-
-
- Free email notifications
-
-
-
-
-
- Support by email
-
-
-
-
-
-
-
-
- + All Upcoming Features
-
-
-
-
-
-
-
-
- Do you require official support for your self-hosted instance?Contact Us
-
-
-
-
+ {{-- BYOS Notice + Support --}}
+
+
You need to bring your own servers from any cloud provider (Hetzner , DigitalOcean, AWS, etc.) or connect any device running a supported OS .
+
Need official support for your self-hosted instance? Contact Us
diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php
index 2fb4b1191..955beb33f 100644
--- a/resources/views/livewire/subscription/show.blade.php
+++ b/resources/views/livewire/subscription/show.blade.php
@@ -3,6 +3,6 @@
Subscription | Coolify
Subscription
-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
new file mode 100644
index 000000000..0c8742d06
--- /dev/null
+++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
@@ -0,0 +1,96 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_456',
+ 'stripe_customer_id' => 'cus_test_456',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_456',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('CancelSubscriptionAtPeriodEnd', function () {
+ test('cancels subscription at period end successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_456', ['cancel_at_period_end' => true])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
+ expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when already set to cancel at period end', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php
new file mode 100644
index 000000000..b6c2d4064
--- /dev/null
+++ b/tests/Feature/Subscription/RefundSubscriptionTest.php
@@ -0,0 +1,271 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_123',
+ 'stripe_customer_id' => 'cus_test_123',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_123',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockRefunds = Mockery::mock(RefundService::class);
+
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->refunds = $this->mockRefunds;
+});
+
+describe('checkEligibility', function () {
+ test('returns eligible when subscription is within 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeTrue();
+ expect($result['days_remaining'])->toBe(20);
+ });
+
+ test('returns ineligible when subscription is past 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['days_remaining'])->toBe(0);
+ expect($result['reason'])->toContain('30-day refund window has expired');
+ });
+
+ test('returns ineligible when subscription is not active', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'canceled',
+ 'start_date' => now()->subDays(5)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ });
+
+ test('returns ineligible when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('No active subscription');
+ });
+
+ test('returns ineligible when invoice is not paid', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not paid');
+ });
+
+ test('returns ineligible when team has already been refunded', function () {
+ $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('already been processed');
+ });
+
+ test('returns ineligible when stripe subscription not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not found in Stripe');
+ });
+});
+
+describe('execute', function () {
+ test('processes refund successfully', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => 'pi_test_123'],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->with([
+ 'subscription' => 'sub_test_123',
+ 'status' => 'paid',
+ 'limit' => 1,
+ ])
+ ->andReturn($invoiceCollection);
+
+ $this->mockRefunds
+ ->shouldReceive('create')
+ ->with(['payment_intent' => 'pi_test_123'])
+ ->andReturn((object) ['id' => 're_test_123']);
+
+ $this->mockSubscriptions
+ ->shouldReceive('cancel')
+ ->with('sub_test_123')
+ ->andReturn((object) ['status' => 'canceled']);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
+ expect($this->subscription->stripe_refunded_at)->not->toBeNull();
+ });
+
+ test('prevents a second refund after re-subscribing', function () {
+ $this->subscription->update([
+ 'stripe_refunded_at' => now()->subDays(15),
+ 'stripe_invoice_paid' => true,
+ 'stripe_subscription_id' => 'sub_test_new_456',
+ ]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already been processed');
+ });
+
+ test('fails when no paid invoice found', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => []];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No paid invoice');
+ });
+
+ test('fails when invoice has no payment intent', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => null],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No payment intent');
+ });
+
+ test('fails when subscription is past refund window', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('30-day refund window');
+ });
+});
diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php
new file mode 100644
index 000000000..8632a4c07
--- /dev/null
+++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php
@@ -0,0 +1,85 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_789',
+ 'stripe_customer_id' => 'cus_test_789',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_789',
+ 'stripe_cancel_at_period_end' => true,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('ResumeSubscription', function () {
+ test('resumes subscription successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_789', ['cancel_at_period_end' => false])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not set to cancel', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
From 76ae720c36c35b042a2dee6123b924405460573b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:24:13 +0100
Subject: [PATCH 2/3] feat(subscription): add Stripe server limit quantity
adjustment flow
Introduce a new `UpdateSubscriptionQuantity` Stripe action to:
- preview prorated due-now and next-cycle recurring costs
- update subscription item quantity with proration invoicing
- revert quantity and void invoice when payment is not completed
Wire the flow into the Livewire subscription actions UI with a new adjust-limit modal,
price preview loading, and confirmation-based updates. Also refactor the subscription
management section layout and fix modal confirmation behavior for temporary 2FA bypass.
Add `Subscription::billingInterval()` helper and comprehensive Pest coverage for
quantity updates, preview calculations, failure/revert paths, and billing interval logic.
---
.../Stripe/UpdateSubscriptionQuantity.php | 192 +++++++++
templates/service-templates-latest.json | 4 +-
templates/service-templates.json | 4 +-
.../UpdateSubscriptionQuantityTest.php | 375 ++++++++++++++++++
4 files changed, 571 insertions(+), 4 deletions(-)
create mode 100644 app/Actions/Stripe/UpdateSubscriptionQuantity.php
create mode 100644 tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
new file mode 100644
index 000000000..f22f56ad0
--- /dev/null
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -0,0 +1,192 @@
+stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
+ }
+
+ /**
+ * Fetch a full price preview for a quantity change from Stripe.
+ * Returns both the prorated amount due now and the recurring cost for the next billing cycle.
+ *
+ * @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null}
+ */
+ public function fetchPricePreview(Team $team, int $quantity): array
+ {
+ $subscription = $team->subscription;
+
+ if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) {
+ return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null];
+ }
+
+ try {
+ $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
+ $item = $stripeSubscription->items->data[0] ?? null;
+
+ if (! $item) {
+ return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null];
+ }
+
+ $currency = strtoupper($item->price->currency ?? 'usd');
+
+ // Upcoming invoice gives us the prorated amount due now
+ $upcomingInvoice = $this->stripe->invoices->upcoming([
+ 'customer' => $subscription->stripe_customer_id,
+ 'subscription' => $subscription->stripe_subscription_id,
+ 'subscription_items' => [
+ ['id' => $item->id, 'quantity' => $quantity],
+ ],
+ 'subscription_proration_behavior' => 'create_prorations',
+ ]);
+
+ // Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal
+ $taxPercentage = 0.0;
+ $taxDescription = null;
+ if (! empty($upcomingInvoice->total_tax_amounts)) {
+ $taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null;
+ if ($taxAmount?->tax_rate) {
+ $taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate);
+ $taxPercentage = (float) ($taxRate->percentage ?? 0);
+ $taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
+ }
+ }
+ if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
+ $taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
+ }
+
+ // Recurring cost for next cycle — read from non-proration invoice lines
+ $recurringSubtotal = 0;
+ foreach ($upcomingInvoice->lines->data as $line) {
+ if (! $line->proration) {
+ $recurringSubtotal += $line->amount;
+ }
+ }
+ $unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0;
+
+ $recurringTax = $taxPercentage > 0
+ ? (int) round($recurringSubtotal * $taxPercentage / 100)
+ : 0;
+ $recurringTotal = $recurringSubtotal + $recurringTax;
+
+ // Due now = amount_due (accounts for customer balance/credits) minus recurring
+ $amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0;
+ $dueNow = $amountDue - $recurringTotal;
+
+ return [
+ 'success' => true,
+ 'error' => null,
+ 'preview' => [
+ 'due_now' => $dueNow,
+ 'recurring_subtotal' => $recurringSubtotal,
+ 'recurring_tax' => $recurringTax,
+ 'recurring_total' => $recurringTotal,
+ 'unit_price' => $unitPrice,
+ 'tax_description' => $taxDescription,
+ 'quantity' => $quantity,
+ 'currency' => $currency,
+ ],
+ ];
+ } catch (\Exception $e) {
+ \Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null];
+ }
+ }
+
+ /**
+ * Update the subscription quantity (server limit) for a team.
+ *
+ * @return array{success: bool, error: string|null}
+ */
+ public function execute(Team $team, int $quantity): array
+ {
+ if ($quantity < 2) {
+ return ['success' => false, 'error' => 'Minimum server limit is 2.'];
+ }
+
+ $subscription = $team->subscription;
+
+ if (! $subscription?->stripe_subscription_id) {
+ return ['success' => false, 'error' => 'No active subscription found.'];
+ }
+
+ if (! $subscription->stripe_invoice_paid) {
+ return ['success' => false, 'error' => 'Subscription is not active.'];
+ }
+
+ try {
+ $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
+ $item = $stripeSubscription->items->data[0] ?? null;
+
+ if (! $item?->id) {
+ return ['success' => false, 'error' => 'Could not find subscription item.'];
+ }
+
+ $previousQuantity = $item->quantity ?? $team->custom_server_limit;
+
+ $updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
+ 'items' => [
+ ['id' => $item->id, 'quantity' => $quantity],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ]);
+
+ // Check if the proration invoice was paid
+ $latestInvoice = $updatedSubscription->latest_invoice;
+ if ($latestInvoice && $latestInvoice->status !== 'paid') {
+ \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
+
+ // Revert subscription quantity on Stripe
+ $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
+ 'items' => [
+ ['id' => $item->id, 'quantity' => $previousQuantity],
+ ],
+ 'proration_behavior' => 'none',
+ ]);
+
+ // Void the unpaid invoice
+ if ($latestInvoice->id) {
+ $this->stripe->invoices->voidInvoice($latestInvoice->id);
+ }
+
+ return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.'];
+ }
+
+ $team->update([
+ 'custom_server_limit' => $quantity,
+ ]);
+
+ ServerLimitCheckJob::dispatch($team);
+
+ \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
+
+ return ['success' => true, 'error' => null];
+ } catch (\Stripe\Exception\InvalidRequestException $e) {
+ \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+ } catch (\Exception $e) {
+ \Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
+ }
+ }
+
+ private function formatAmount(int $cents, string $currency): string
+ {
+ return strtoupper($currency) === 'USD'
+ ? '$'.number_format($cents / 100, 2)
+ : number_format($cents / 100, 2).' '.$currency;
+ }
+}
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index e343e6293..6c7af5dc5 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"photos",
"gallery",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 2a08e7b4b..58f990de6 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"photos",
"gallery",
diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
new file mode 100644
index 000000000..3e13170f0
--- /dev/null
+++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
@@ -0,0 +1,375 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_qty',
+ 'stripe_customer_id' => 'cus_test_qty',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_qty',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockTaxRates = Mockery::mock(TaxRateService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->taxRates = $this->mockTaxRates;
+
+ $this->stripeSubscriptionResponse = (object) [
+ 'items' => (object) [
+ 'data' => [(object) [
+ 'id' => 'si_item_123',
+ 'quantity' => 2,
+ 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
+ ]],
+ ],
+ ];
+});
+
+describe('UpdateSubscriptionQuantity::execute', function () {
+ test('updates quantity successfully', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['status' => 'paid'],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->toBe(5);
+
+ Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
+ return $job->team->id === $this->team->id;
+ });
+ });
+
+ test('reverts subscription and voids invoice when payment fails', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ // First update: changes quantity but payment fails
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
+ ]);
+
+ // Revert: restores original quantity
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 2],
+ ],
+ 'proration_behavior' => 'none',
+ ])
+ ->andReturn((object) ['status' => 'active']);
+
+ // Void the unpaid invoice
+ $this->mockInvoices
+ ->shouldReceive('voidInvoice')
+ ->with('in_failed_123')
+ ->once();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Payment failed');
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->not->toBe(5);
+
+ Queue::assertNotPushed(ServerLimitCheckJob::class);
+ });
+
+ test('rejects quantity below minimum of 2', function () {
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 1);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Minimum server limit is 2');
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when subscription item cannot be found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not find subscription item');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+
+ test('handles generic exception gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('Network error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('unexpected error');
+ });
+});
+
+describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
+ test('returns full preview with proration and recurring cost with tax', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->with([
+ 'customer' => 'cus_test_qty',
+ 'subscription' => 'sub_test_qty',
+ 'subscription_items' => [
+ ['id' => 'si_item_123', 'quantity' => 3],
+ ],
+ 'subscription_proration_behavior' => 'create_prorations',
+ ])
+ ->andReturn((object) [
+ 'amount_due' => 2540,
+ 'total' => 2540,
+ 'subtotal' => 2000,
+ 'tax' => 540,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => -300, 'proration' => true], // credit for unused
+ (object) ['amount' => 800, 'proration' => true], // charge for new qty
+ (object) ['amount' => 1500, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [
+ (object) ['tax_rate' => 'txr_123'],
+ ],
+ ]);
+
+ $this->mockTaxRates
+ ->shouldReceive('retrieve')
+ ->with('txr_123')
+ ->andReturn((object) [
+ 'display_name' => 'VAT',
+ 'jurisdiction' => 'HU',
+ 'percentage' => 27,
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 3);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (2540) - recurring total (1905) = 635
+ expect($result['preview']['due_now'])->toBe(635);
+ // Recurring: 3 × $5.00 = $15.00
+ expect($result['preview']['recurring_subtotal'])->toBe(1500);
+ // Tax: $15.00 × 27% = $4.05
+ expect($result['preview']['recurring_tax'])->toBe(405);
+ // Total: $15.00 + $4.05 = $19.05
+ expect($result['preview']['recurring_total'])->toBe(1905);
+ expect($result['preview']['unit_price'])->toBe(500);
+ expect($result['preview']['tax_description'])->toContain('VAT');
+ expect($result['preview']['tax_description'])->toContain('27%');
+ expect($result['preview']['quantity'])->toBe(3);
+ expect($result['preview']['currency'])->toBe('USD');
+ });
+
+ test('returns preview without tax when no tax applies', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->andReturn((object) [
+ 'amount_due' => 1250,
+ 'total' => 1250,
+ 'subtotal' => 1250,
+ 'tax' => 0,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => 250, 'proration' => true], // proration charge
+ (object) ['amount' => 1000, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 2);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (1250) - recurring total (1000) = 250
+ expect($result['preview']['due_now'])->toBe(250);
+ // 2 × $5.00 = $10.00, no tax
+ expect($result['preview']['recurring_subtotal'])->toBe(1000);
+ expect($result['preview']['recurring_tax'])->toBe(0);
+ expect($result['preview']['recurring_total'])->toBe(1000);
+ expect($result['preview']['tax_description'])->toBeNull();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['preview'])->toBeNull();
+ });
+
+ test('fails when subscription item not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not retrieve subscription details');
+ });
+
+ test('handles Stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('API error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not load price preview');
+ expect($result['preview'])->toBeNull();
+ });
+});
+
+describe('Subscription billingInterval', function () {
+ test('returns monthly for monthly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+
+ test('returns yearly for yearly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('yearly');
+ });
+
+ test('defaults to monthly when plan id is null', function () {
+ $this->subscription->update(['stripe_plan_id' => null]);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+});
From d3b8d70f08aee1e8a82842cf559c74921894ece2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:28:16 +0100
Subject: [PATCH 3/3] 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.
---
.../Stripe/UpdateSubscriptionQuantity.php | 9 +-
app/Http/Middleware/TrustHosts.php | 7 +
app/Http/Middleware/TrustProxies.php | 22 ++
app/Livewire/Subscription/Actions.php | 49 +++
app/Models/Subscription.php | 14 +
.../components/modal-confirmation.blade.php | 2 +-
.../livewire/subscription/actions.blade.php | 305 ++++++++++++------
.../Feature/SecureCookieAutoDetectionTest.php | 64 ++++
tests/Feature/TrustHostsMiddlewareTest.php | 50 +++
9 files changed, 423 insertions(+), 99 deletions(-)
create mode 100644 tests/Feature/SecureCookieAutoDetectionTest.php
diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
index f22f56ad0..c181e988d 100644
--- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -8,6 +8,10 @@
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 @@ public function fetchPricePreview(Team $team, int $quantity): array
$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 @@ public function fetchPricePreview(Team $team, int $quantity): array
*/
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;
diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php
index f0b9d67f2..5fca583d9 100644
--- a/app/Http/Middleware/TrustHosts.php
+++ b/app/Http/Middleware/TrustHosts.php
@@ -91,6 +91,13 @@ public function hosts(): array
// 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);
}
}
diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php
index 559dd2fc3..a4764047b 100644
--- a/app/Http/Middleware/TrustProxies.php
+++ b/app/Http/Middleware/TrustProxies.php
@@ -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);
+ });
+ }
}
diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php
index 4ac95adfb..2d5392240 100644
--- a/app/Livewire/Subscription/Actions.php
+++ b/app/Livewire/Subscription/Actions.php
@@ -5,6 +5,7 @@
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
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 00f85ced5..69d7cbf0d 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -20,6 +20,20 @@ public function team()
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()) {
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index b14888040..e77b52076 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -35,7 +35,7 @@
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
if ($temporaryDisableTwoStepConfirmation) {
$disableTwoStepConfirmation = false;
- $skipPasswordConfirmation = false;
+ // Password confirmation requirement is not affected by temporary two-step disable
}
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php
index 2f33d4f70..4b276aaf6 100644
--- a/resources/views/livewire/subscription/actions.blade.php
+++ b/resources/views/livewire/subscription/actions.blade.php
@@ -1,7 +1,39 @@
@if (subscriptionProvider() === 'stripe')
{{-- Plan Overview --}}
-
+
Plan Overview
{{-- Current Plan Card --}}
@@ -25,11 +57,12 @@
- {{-- Server Limit Card --}}
-
+ {{-- Paid Servers Card --}}
+
Paid Servers
-
{{ $server_limits }}
-
Included in your plan
+
+
Click to adjust
{{-- Active Servers Card --}}
@@ -49,103 +82,183 @@ class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:
subscription. Excess servers will be deactivated.
@endif
+
+ {{-- Adjust Server Limit Modal --}}
+
+
+
+
+
+
+
Adjust Server Limit
+
+
+
+
+
+
+
+ {{-- Server count input --}}
+
+
Paid Servers
+
+
+
+ Calculate Price
+
+
+
+
+ {{-- Loading --}}
+
+
+
+
+ {{-- Price Preview --}}
+
+
+
Due now
+
+ Prorated charge
+
+
+
Charged immediately to your payment method.
+
+
+
Next billing cycle
+
+
+
+
+
+
+
+
+
+
+ Total / month
+
+
+
+
+
+ {{-- Update Button with Confirmation --}}
+
+
+
+ Update Server Limit
+
+
+
+
+
+ {{-- Reduction Warning --}}
+
+
+ Reducing below your active server count will deactivate excess servers.
+
+
+
+
+
+
- {{-- Manage Plan --}}
+ {{-- Billing, Refund & Cancellation --}}
- Manage Plan
-
-
-
-
-
-
- Manage Billing on Stripe
-
-
-
Change your server quantity, update payment methods, or view
- invoices.
+
Manage Subscription
+
+ {{-- Billing --}}
+
+
+
+
+ Manage Billing on Stripe
+
+
+ {{-- Resume or Cancel --}}
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end)
+
Resume Subscription
+ @else
+
+
+ @endif
+
+ {{-- Refund --}}
+ @if ($refundCheckLoading)
+
+ @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
+
+ @endif
+
+ {{-- Contextual notes --}}
+ @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
+
Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.
+ @elseif ($refundAlreadyUsed)
+
Refund already processed. Each team is eligible for one refund only.
+ @endif
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end)
+
Your subscription is set to cancel at the end of the billing period.
+ @endif
- {{-- Refund Section --}}
- @if ($refundCheckLoading)
-
- @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
-
- Refund
-
-
-
-
-
You are eligible for a full refund.
- {{ $refundDaysRemaining }} days remaining
- in the 30-day refund window.
-
-
- @elseif ($refundAlreadyUsed)
-
- Refund
- A refund has already been processed for this team. Each team is
- eligible for one refund only to prevent abuse.
-
- @endif
-
- {{-- Resume / Cancel Subscription Section --}}
- @if (currentTeam()->subscription->stripe_cancel_at_period_end)
-
- Resume Subscription
-
-
- Resume Subscription
-
-
Your subscription is set to cancel at the end of the billing
- period. Resume to continue your plan.
-
-
- @else
-
- Cancel Subscription
-
-
-
-
-
-
Cancel your subscription immediately or at the end of the
- current billing period.
-
-
- @endif
-
Need help?
Contact us.
diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php
new file mode 100644
index 000000000..4db0a7681
--- /dev/null
+++ b/tests/Feature/SecureCookieAutoDetectionTest.php
@@ -0,0 +1,64 @@
+ 0], ['fqdn' => null]);
+ // Ensure session.secure starts unconfigured for each test
+ config(['session.secure' => null]);
+});
+
+it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('does not set session.secure for plain HTTP requests', function () {
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeNull();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
+ config(['session.secure' => false]);
+
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ // Explicit false must not be overridden — our check is `=== null` only
+ expect(config('session.secure'))->toBeFalse();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
+ config(['session.secure' => true]);
+
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
+ $response = $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ $response->assertSuccessful();
+
+ $cookieName = config('session.cookie');
+ $sessionCookie = collect($response->headers->all('set-cookie'))
+ ->first(fn ($c) => str_contains($c, $cookieName));
+
+ expect($sessionCookie)->not->toBeNull()
+ ->and(strtolower($sessionCookie))->toContain('; secure');
+});
diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php
index b745259fe..5c60b30d6 100644
--- a/tests/Feature/TrustHostsMiddlewareTest.php
+++ b/tests/Feature/TrustHostsMiddlewareTest.php
@@ -286,6 +286,56 @@
expect($response->status())->not->toBe(400);
});
+it('trusts localhost when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('localhost');
+});
+
+it('trusts 127.0.0.1 when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('127.0.0.1');
+});
+
+it('trusts IPv6 loopback when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('[::1]');
+});
+
+it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $response = $this->get('/', [
+ 'Host' => 'localhost',
+ ]);
+
+ // Should NOT be rejected as untrusted host (would be 400)
+ expect($response->status())->not->toBe(400);
+});
+
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation