From 8f2800a9e5196d96c75536ec88568883f8c25b42 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 26 Feb 2026 18:22:03 +0100
Subject: [PATCH] chore: prepare for PR
---
.../Stripe/CancelSubscriptionAtPeriodEnd.php | 60 ++++
app/Actions/Stripe/RefundSubscription.php | 141 +++++++++
app/Actions/Stripe/ResumeSubscription.php | 56 ++++
app/Livewire/Subscription/Actions.php | 138 ++++++++-
app/Models/Subscription.php | 7 +
...ipe_refunded_at_to_subscriptions_table.php | 25 ++
.../components/modal-confirmation.blade.php | 31 +-
resources/views/livewire/dashboard.blade.php | 6 -
.../views/livewire/layout-popups.blade.php | 27 ++
.../livewire/subscription/actions.blade.php | 185 +++++++++---
.../livewire/subscription/index.blade.php | 53 ++--
.../subscription/pricing-plans.blade.php | 271 ++++++++----------
.../livewire/subscription/show.blade.php | 2 +-
.../CancelSubscriptionActionsTest.php | 96 +++++++
.../Subscription/RefundSubscriptionTest.php | 271 ++++++++++++++++++
.../Subscription/ResumeSubscriptionTest.php | 85 ++++++
16 files changed, 1212 insertions(+), 242 deletions(-)
create mode 100644 app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php
create mode 100644 app/Actions/Stripe/RefundSubscription.php
create mode 100644 app/Actions/Stripe/ResumeSubscription.php
create mode 100644 database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php
create mode 100644 tests/Feature/Subscription/CancelSubscriptionActionsTest.php
create mode 100644 tests/Feature/Subscription/RefundSubscriptionTest.php
create mode 100644 tests/Feature/Subscription/ResumeSubscriptionTest.php
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
Change your server quantity, update payment methods, or view + invoices.
You are eligible for a full refund. + {{ $refundDaysRemaining }} days remaining + in the 30-day refund window.
+A refund has already been processed for this team. Each team is + eligible for one refund only to prevent abuse.
+Your subscription is set to cancel at the end of the billing + period. Resume to continue your plan.
+Cancel your subscription immediately or at the end of the + current billing period.
+Open the following link, navigate to the button and pay your unpaid/past due subscription. @@ -34,18 +31,20 @@
Dynamic pricing based on the number of servers you connect.
+ +- 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 --}} +
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