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/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
new file mode 100644
index 000000000..c181e988d
--- /dev/null
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -0,0 +1,197 @@
+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.'%';
+ }
+ }
+ // 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);
+ }
+
+ // 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 < self::MIN_SERVER_LIMIT) {
+ return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
+ }
+
+ $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/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 1388d3244..2d5392240 100644
--- a/app/Livewire/Subscription/Actions.php
+++ b/app/Livewire/Subscription/Actions.php
@@ -2,21 +2,204 @@
namespace App\Livewire\Subscription;
+use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
+use App\Actions\Stripe\RefundSubscription;
+use App\Actions\Stripe\ResumeSubscription;
+use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Team;
+use Illuminate\Support\Facades\Hash;
use Livewire\Component;
+use Stripe\StripeClient;
class Actions extends Component
{
public $server_limits = 0;
- public function mount()
+ 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;
+
+ public bool $refundCheckLoading = true;
+
+ public bool $refundAlreadyUsed = false;
+
+ public function mount(): void
{
$this->server_limits = Team::serverLimit();
+ $this->quantity = (int) $this->server_limits;
}
- public function stripeCustomerPortal()
+ 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
+ {
+ $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..69d7cbf0d 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -8,11 +8,32 @@ class Subscription extends Model
{
protected $guarded = [];
+ protected function casts(): array
+ {
+ return [
+ 'stripe_refunded_at' => 'datetime',
+ ];
+ }
+
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/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..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;
@@ -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
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 + + +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