diff --git a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php
new file mode 100644
index 000000000..34c7d194a
--- /dev/null
+++ b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php
@@ -0,0 +1,60 @@
+stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
+ }
+
+ /**
+ * Cancel the team's subscription at the end of the current billing period.
+ *
+ * @return array{success: bool, error: string|null}
+ */
+ public function execute(Team $team): array
+ {
+ $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.'];
+ }
+
+ if ($subscription->stripe_cancel_at_period_end) {
+ return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.'];
+ }
+
+ try {
+ $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
+ 'cancel_at_period_end' => true,
+ ]);
+
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => true,
+ ]);
+
+ \Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
+
+ return ['success' => true, 'error' => null];
+ } catch (\Stripe\Exception\InvalidRequestException $e) {
+ \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+ } catch (\Exception $e) {
+ \Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
+ }
+ }
+}
diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php
new file mode 100644
index 000000000..021cba13e
--- /dev/null
+++ b/app/Actions/Stripe/RefundSubscription.php
@@ -0,0 +1,141 @@
+stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
+ }
+
+ /**
+ * Check if the team's subscription is eligible for a refund.
+ *
+ * @return array{eligible: bool, days_remaining: int, reason: string}
+ */
+ public function checkEligibility(Team $team): array
+ {
+ $subscription = $team->subscription;
+
+ if ($subscription?->stripe_refunded_at) {
+ return $this->ineligible('A refund has already been processed for this team.');
+ }
+
+ if (! $subscription?->stripe_subscription_id) {
+ return $this->ineligible('No active subscription found.');
+ }
+
+ if (! $subscription->stripe_invoice_paid) {
+ return $this->ineligible('Subscription invoice is not paid.');
+ }
+
+ try {
+ $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
+ } catch (\Stripe\Exception\InvalidRequestException $e) {
+ return $this->ineligible('Subscription not found in Stripe.');
+ }
+
+ if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
+ return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
+ }
+
+ $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
+ $daysSinceStart = (int) $startDate->diffInDays(now());
+ $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
+
+ if ($daysRemaining <= 0) {
+ return $this->ineligible('The 30-day refund window has expired.');
+ }
+
+ return [
+ 'eligible' => true,
+ 'days_remaining' => $daysRemaining,
+ 'reason' => 'Eligible for refund.',
+ ];
+ }
+
+ /**
+ * Process a full refund and cancel the subscription.
+ *
+ * @return array{success: bool, error: string|null}
+ */
+ public function execute(Team $team): array
+ {
+ $eligibility = $this->checkEligibility($team);
+
+ if (! $eligibility['eligible']) {
+ return ['success' => false, 'error' => $eligibility['reason']];
+ }
+
+ $subscription = $team->subscription;
+
+ try {
+ $invoices = $this->stripe->invoices->all([
+ 'subscription' => $subscription->stripe_subscription_id,
+ 'status' => 'paid',
+ 'limit' => 1,
+ ]);
+
+ if (empty($invoices->data)) {
+ return ['success' => false, 'error' => 'No paid invoice found to refund.'];
+ }
+
+ $invoice = $invoices->data[0];
+ $paymentIntentId = $invoice->payment_intent;
+
+ if (! $paymentIntentId) {
+ return ['success' => false, 'error' => 'No payment intent found on the invoice.'];
+ }
+
+ $this->stripe->refunds->create([
+ 'payment_intent' => $paymentIntentId,
+ ]);
+
+ $this->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' => 'Refund requested by user',
+ 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
+ 'stripe_refunded_at' => now(),
+ ]);
+
+ $team->subscriptionEnded();
+
+ \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
+
+ return ['success' => true, 'error' => null];
+ } catch (\Stripe\Exception\InvalidRequestException $e) {
+ \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+ } catch (\Exception $e) {
+ \Log::error("Refund error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
+ }
+ }
+
+ /**
+ * @return array{eligible: bool, days_remaining: int, reason: string}
+ */
+ private function ineligible(string $reason): array
+ {
+ return [
+ 'eligible' => false,
+ 'days_remaining' => 0,
+ 'reason' => $reason,
+ ];
+ }
+}
diff --git a/app/Actions/Stripe/ResumeSubscription.php b/app/Actions/Stripe/ResumeSubscription.php
new file mode 100644
index 000000000..d8019def7
--- /dev/null
+++ b/app/Actions/Stripe/ResumeSubscription.php
@@ -0,0 +1,56 @@
+stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
+ }
+
+ /**
+ * Resume a subscription that was set to cancel at the end of the billing period.
+ *
+ * @return array{success: bool, error: string|null}
+ */
+ public function execute(Team $team): array
+ {
+ $subscription = $team->subscription;
+
+ if (! $subscription?->stripe_subscription_id) {
+ return ['success' => false, 'error' => 'No active subscription found.'];
+ }
+
+ if (! $subscription->stripe_cancel_at_period_end) {
+ return ['success' => false, 'error' => 'Subscription is not set to cancel.'];
+ }
+
+ try {
+ $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
+ 'cancel_at_period_end' => false,
+ ]);
+
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => false,
+ ]);
+
+ \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
+
+ return ['success' => true, 'error' => null];
+ } catch (\Stripe\Exception\InvalidRequestException $e) {
+ \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+ } catch (\Exception $e) {
+ \Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage());
+
+ return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
+ }
+ }
+}
diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php
index 1388d3244..4ac95adfb 100644
--- a/app/Livewire/Subscription/Actions.php
+++ b/app/Livewire/Subscription/Actions.php
@@ -2,21 +2,155 @@
namespace App\Livewire\Subscription;
+use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
+use App\Actions\Stripe\RefundSubscription;
+use App\Actions\Stripe\ResumeSubscription;
use App\Models\Team;
+use Illuminate\Support\Facades\Hash;
use Livewire\Component;
+use Stripe\StripeClient;
class Actions extends Component
{
public $server_limits = 0;
- public function mount()
+ 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();
}
- public function stripeCustomerPortal()
+ 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 contact us .');
+
+ 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 contact us .');
+
+ 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 contact us .');
+
+ 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 contact us .');
+
+ 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 contact us .');
+
+ 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());
+ }
+ }
}
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 1bd84a664..00f85ced5 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -8,6 +8,13 @@ class Subscription extends Model
{
protected $guarded = [];
+ protected function casts(): array
+ {
+ return [
+ 'stripe_refunded_at' => 'datetime',
+ ];
+ }
+
public function team()
{
return $this->belongsTo(Team::class);
diff --git a/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php
new file mode 100644
index 000000000..76420fb5c
--- /dev/null
+++ b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php
@@ -0,0 +1,25 @@
+timestamp('stripe_refunded_at')->nullable()->after('stripe_past_due');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('subscriptions', function (Blueprint $table) {
+ $table->dropColumn('stripe_refunded_at');
+ });
+ }
+};
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index 73939092e..b14888040 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -59,6 +59,7 @@
confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation),
submitAction: @js($submitAction),
dispatchAction: @js($dispatchAction),
+ submitting: false,
passwordError: '',
selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()),
dispatchEvent: @js($dispatchEvent),
@@ -70,6 +71,7 @@
this.step = this.initialStep;
this.deleteText = '';
this.password = '';
+ this.submitting = false;
this.userConfirmationText = '';
this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all());
$wire.$refresh();
@@ -320,8 +322,8 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
@endif
{
+ submitting = false;
+ modalOpen = false;
+ resetModal();
+ }).catch(() => {
+ submitting = false;
+ });
}
">
-
+
+
@@ -373,22 +381,27 @@ class="block text-sm font-medium text-gray-700 dark:text-gray-300">
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Back
- {
+ submitting = false;
if (result === true) {
modalOpen = false;
resetModal();
} else {
passwordError = result;
- password = ''; // Clear the password field
+ password = '';
}
+ }).catch(() => {
+ submitting = false;
});
">
-
+
+
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index a58ca0a00..908c4a98a 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -7,12 +7,6 @@
@endif
Dashboard
Your self-hosted infrastructure.
- @if (request()->query->get('success'))
-
- Your subscription has been activated! Welcome onboard! It could take a few seconds before your
- subscription is activated. Please be patient.
-
- @endif
diff --git a/resources/views/livewire/layout-popups.blade.php b/resources/views/livewire/layout-popups.blade.php
index 51ca80fde..1aa533c03 100644
--- a/resources/views/livewire/layout-popups.blade.php
+++ b/resources/views/livewire/layout-popups.blade.php
@@ -132,6 +132,33 @@ class="font-bold dark:text-white">Stripe
@endif
+ @if (request()->query->get('cancelled'))
+
+
+
+
+
+
Subscription Error. Something went wrong. Please try
+ again or contact support .
+
+
+ @endif
+ @if (request()->query->get('success'))
+
+
+
+
+
+
Welcome onboard! Your subscription has been
+ activated. It could take a few seconds before it's fully active.
+
+
+ @endif
@if (currentTeam()->subscriptionPastOverDue())
WARNING: Your subscription is in over-due. If your
diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php
index 516a57dd3..2f33d4f70 100644
--- a/resources/views/livewire/subscription/actions.blade.php
+++ b/resources/views/livewire/subscription/actions.blade.php
@@ -1,53 +1,154 @@
-
+
@if (subscriptionProvider() === 'stripe')
-
-
Your current plan
-
Tier:
- @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
- Pay-as-you-go
- @else
- {{ data_get(currentTeam(), 'subscription')->type() }}
- @endif
+ {{-- Plan Overview --}}
+
+ Plan Overview
+
+ {{-- Current Plan Card --}}
+
+
Current Plan
+
+ @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
+ Pay-as-you-go
+ @else
+ {{ data_get(currentTeam(), 'subscription')->type() }}
+ @endif
+
+
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end)
+ Cancelling at end of period
+ @else
+ Active
+ · Invoice
+ {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}
+ @endif
+
+
-
+ {{-- Server Limit Card --}}
+
+
Paid Servers
+
{{ $server_limits }}
+
Included in your plan
+
- @if (currentTeam()->subscription->stripe_cancel_at_period_end)
- Subscription is active but on cancel period.
- @else
- Subscription is active. Last invoice is
- {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.
- @endif
-
-
Number of paid servers:
-
{{ $server_limits }}
-
-
-
Currently active servers:
-
{{ currentTeam()->servers->count() }}
+ {{-- Active Servers Card --}}
+
+
Active Servers
+
+ {{ currentTeam()->servers->count() }}
+
+
Currently running
+
+
@if (currentTeam()->serverOverflow())
-
- You must delete {{ currentTeam()->servers->count() - $server_limits }} servers,
- or upgrade your subscription. {{ currentTeam()->servers->count() - $server_limits }} servers will be
- deactivated.
+
+ You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your
+ subscription. Excess servers will be deactivated.
@endif
- Change Server Quantity
-
- Manage your subscription
- Cancel, upgrade or downgrade your subscription.
-
-
Go to
-
-
-
+
+
+ {{-- Manage Plan --}}
+
+ Manage Plan
+
+
+
+
+
+
+ Manage Billing on Stripe
+
+
+
Change your server quantity, update payment methods, or view
+ invoices.
-
-
- If you have any problems, please
contact us.
+
+
+ {{-- 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
+
+
@endif
diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php
index d1d933e04..c78af77f9 100644
--- a/resources/views/livewire/subscription/index.blade.php
+++ b/resources/views/livewire/subscription/index.blade.php
@@ -3,29 +3,26 @@
Subscribe | Coolify
@if (auth()->user()->isAdminFromSession())
- @if (request()->query->get('cancelled'))
-
-
-
-
-
Something went wrong with your subscription. Please try again or contact
- support.
-
- @endif
Subscriptions
@if ($loading)
-
- Loading your subscription status...
+
+
@else
@if ($isUnpaid)
-
- Your last payment was failed for Coolify Cloud.
-
+
+
+
+
+
+
Payment Failed. Your last payment for Coolify
+ Cloud has failed.
+
+
Open the following link, navigate to the button and pay your unpaid/past due
subscription.
@@ -34,18 +31,20 @@
@else
@if (config('subscription.provider') === 'stripe')
-
$isCancelled,
- 'pb-10' => !$isCancelled,
- ])>
- @if ($isCancelled)
-
-
It looks like your previous subscription has been cancelled, because you forgot to
- pay
- the bills. Please subscribe again to continue using Coolify.
+ @if ($isCancelled)
+
+
+
+
+
+
No Active Subscription. Subscribe to
+ a plan to start using Coolify Cloud.
- @endif
-
+
+ @endif
+
$isCancelled, 'pb-10' => !$isCancelled])>
@endif
@endif
diff --git a/resources/views/livewire/subscription/pricing-plans.blade.php b/resources/views/livewire/subscription/pricing-plans.blade.php
index 52811f288..45edc39ad 100644
--- a/resources/views/livewire/subscription/pricing-plans.blade.php
+++ b/resources/views/livewire/subscription/pricing-plans.blade.php
@@ -1,162 +1,123 @@
-
-
-
-
- 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');
+ });
+});