chore: prepare for PR

This commit is contained in:
Andras Bacsai 2026-02-26 18:22:03 +01:00
parent 78aea9a7ec
commit 8f2800a9e5
16 changed files with 1212 additions and 242 deletions

View file

@ -0,0 +1,60 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->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.'];
}
}
}

View file

@ -0,0 +1,141 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class RefundSubscription
{
private StripeClient $stripe;
private const REFUND_WINDOW_DAYS = 30;
public function __construct(?StripeClient $stripe = null)
{
$this->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,
];
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class ResumeSubscription
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->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.'];
}
}
}

View file

@ -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 <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
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 <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
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 <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
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 <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
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 <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
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());
}
}
}

View file

@ -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);

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->timestamp('stripe_refunded_at')->nullable()->after('stripe_past_due');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn('stripe_refunded_at');
});
}
};

View file

@ -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">
</x-forms.button>
@endif
<x-forms.button
x-bind:disabled="!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
confirmationText"
x-bind:disabled="submitting || (!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
confirmationText)"
class="w-auto" isError
@click="
if (dispatchEvent) {
@ -330,12 +332,18 @@ class="w-auto" isError
if (confirmWithPassword && !skipPasswordConfirmation) {
step++;
} else {
modalOpen = false;
resetModal();
submitForm();
submitting = true;
submitForm().then((result) => {
submitting = false;
modalOpen = false;
resetModal();
}).catch(() => {
submitting = false;
});
}
">
<span x-text="step2ButtonText"></span>
<span x-show="!submitting" x-text="step2ButtonText"></span>
<x-loading x-show="submitting" text="Processing..." />
</x-forms.button>
</div>
</div>
@ -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
</x-forms.button>
<x-forms.button x-bind:disabled="!password" class="w-auto" isError
<x-forms.button x-bind:disabled="!password || submitting" class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
submitting = true;
submitForm().then((result) => {
submitting = false;
if (result === true) {
modalOpen = false;
resetModal();
} else {
passwordError = result;
password = ''; // Clear the password field
password = '';
}
}).catch(() => {
submitting = false;
});
">
<span x-text="step3ButtonText"></span>
<span x-show="!submitting" x-text="step3ButtonText"></span>
<x-loading x-show="submitting" text="Processing..." />
</x-forms.button>
</div>
</div>

View file

@ -7,12 +7,6 @@
@endif
<h1>Dashboard</h1>
<div class="subtitle">Your self-hosted infrastructure.</div>
@if (request()->query->get('success'))
<div class=" mb-10 font-bold alert alert-success">
Your subscription has been activated! Welcome onboard! It could take a few seconds before your
subscription is activated.<br> Please be patient.
</div>
@endif
<section class="-mt-2">
<div class="flex items-center gap-2 pb-2">

View file

@ -132,6 +132,33 @@ class="font-bold dark:text-white">Stripe</a></x-forms.button>
</x-popup>
</span>
@endif
@if (request()->query->get('cancelled'))
<x-banner>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
<span><span class="font-bold text-red-500">Subscription Error.</span> Something went wrong. Please try
again or <a class="underline dark:text-white"
href="{{ config('constants.urls.contact') }}" target="_blank">contact support</a>.</span>
</div>
</x-banner>
@endif
@if (request()->query->get('success'))
<x-banner>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<span><span class="font-bold text-green-500">Welcome onboard!</span> Your subscription has been
activated. It could take a few seconds before it's fully active.</span>
</div>
</x-banner>
@endif
@if (currentTeam()->subscriptionPastOverDue())
<x-banner :closable=false>
<div><span class="font-bold text-red-500">WARNING:</span> Your subscription is in over-due. If your

View file

@ -1,53 +1,154 @@
<div>
<div wire:init="loadRefundEligibility">
@if (subscriptionProvider() === 'stripe')
<div class="pt-4">
<h2>Your current plan</h2>
<div class="pb-4">Tier: <strong class="dark:text-warning">
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
Pay-as-you-go
@else
{{ data_get(currentTeam(), 'subscription')->type() }}
@endif
{{-- Plan Overview --}}
<section class="-mt-2">
<h3 class="pb-2">Plan Overview</h3>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
{{-- Current Plan Card --}}
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Current Plan</div>
<div class="text-xl font-bold dark:text-warning">
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
Pay-as-you-go
@else
{{ data_get(currentTeam(), 'subscription')->type() }}
@endif
</div>
<div class="pt-2 text-sm">
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<span class="text-red-500 font-medium">Cancelling at end of period</span>
@else
<span class="text-green-500 font-medium">Active</span>
<span class="text-neutral-500"> &middot; Invoice
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}</span>
@endif
</div>
</div>
</strong></div>
{{-- Server Limit Card --}}
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Paid Servers</div>
<div class="text-xl font-bold dark:text-white">{{ $server_limits }}</div>
<div class="pt-2 text-sm text-neutral-500">Included in your plan</div>
</div>
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<div class="pb-2">Subscription is active but on cancel period.</div>
@else
<div class="pb-2">Subscription is active. Last invoice is
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.</div>
@endif
<div class="flex items-center gap-2">
<div class="w-48">Number of paid servers:</div>
<div class="text-xl font-bold dark:text-white">{{ $server_limits }}</div>
</div>
<div class="flex items-center gap-2">
<div class="w-48">Currently active servers:</div>
<div class="text-xl font-bold dark:text-white">{{ currentTeam()->servers->count() }}</div>
{{-- Active Servers Card --}}
<div
class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400 {{ currentTeam()->serverOverflow() ? 'border-red-500 dark:border-red-500' : '' }}">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Active Servers</div>
<div class="text-xl font-bold {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">
{{ currentTeam()->servers->count() }}
</div>
<div class="pt-2 text-sm text-neutral-500">Currently running</div>
</div>
</div>
@if (currentTeam()->serverOverflow())
<x-callout type="danger" title="WARNING" class="my-4">
You must delete {{ currentTeam()->servers->count() - $server_limits }} servers,
or upgrade your subscription. {{ currentTeam()->servers->count() - $server_limits }} servers will be
deactivated.
<x-callout type="danger" title="Server limit exceeded" class="mt-4">
You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your
subscription. Excess servers will be deactivated.
</x-callout>
@endif
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>Change Server Quantity
</x-forms.button>
<h2 class="pt-4">Manage your subscription</h2>
<div class="pb-4">Cancel, upgrade or downgrade your subscription.</div>
<div class="flex gap-2">
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>Go to <svg
xmlns="http://www.w3.org/2000/svg" class="w-12 " viewBox="0 0 512 214">
<path fill="#635BFF"
d="M512 110.08c0-36.409-17.636-65.138-51.342-65.138c-33.85 0-54.33 28.73-54.33 64.854c0 42.808 24.179 64.426 58.88 64.426c16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823c-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658Zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756c8.675 0 17.92 6.685 17.92 22.756h-36.694Zm-90.31-51.627c-13.939 0-22.899 6.542-27.876 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537l.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96c25.458 0 48.64-20.48 48.64-65.564c-.142-41.245-23.609-63.716-48.498-63.716Zm-8.534 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968c12.942 0 21.902 14.506 21.902 33.137c0 19.058-8.818 33.28-21.902 33.28ZM241.493 36.551l35.698-7.68V0l-35.698 7.538V36.55Zm0 10.809h35.698v124.444h-35.698V47.36Zm-38.257 10.524L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524Zm-71.112-41.386l-34.702 7.395l-.142 113.92c0 21.05 15.787 36.551 36.836 36.551c11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862ZM35.982 83.484c0-5.546 4.551-7.68 12.09-7.68c10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92c0 6.541-5.688 8.675-13.653 8.675c-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106c29.582 0 49.92-14.648 49.92-40.106c-.142-42.382-54.329-34.845-54.329-50.774Z" />
</svg>
</x-forms.button>
</section>
{{-- Manage Plan --}}
<section>
<h3 class="pb-2">Manage Plan</h3>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-4">
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />
</svg>
Manage Billing on Stripe
</x-forms.button>
</div>
<p class="text-sm text-neutral-500">Change your server quantity, update payment methods, or view
invoices.</p>
</div>
</div>
<div class="pt-4">
If you have any problems, please <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
target="_blank">contact us.</a>
</section>
{{-- Refund Section --}}
@if ($refundCheckLoading)
<section>
<h3 class="pb-2">Refund</h3>
<x-loading text="Checking refund eligibility..." />
</section>
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
<section>
<h3 class="pb-2">Refund</h3>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-4">
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
isErrorButton submitAction="refundSubscription"
:actions="[
'Your latest payment will be fully refunded.',
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
step2ButtonText="Confirm Refund & Cancel" />
</div>
<p class="text-sm text-neutral-500">You are eligible for a full refund.
<strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining
in the 30-day refund window.</p>
</div>
</section>
@elseif ($refundAlreadyUsed)
<section>
<h3 class="pb-2">Refund</h3>
<p class="text-sm text-neutral-500">A refund has already been processed for this team. Each team is
eligible for one refund only to prevent abuse.</p>
</section>
@endif
{{-- Resume / Cancel Subscription Section --}}
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<section>
<h3 class="pb-2">Resume Subscription</h3>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-4">
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
</div>
<p class="text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing
period. Resume to continue your plan.</p>
</div>
</section>
@else
<section>
<h3 class="pb-2">Cancel Subscription</h3>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<x-modal-confirmation title="Cancel at End of Billing Period?"
buttonTitle="Cancel at Period End" submitAction="cancelAtPeriodEnd"
:actions="[
'Your subscription will remain active until the end of the current billing period.',
'No further charges will be made after the current period.',
'You can resubscribe at any time.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
isErrorButton submitAction="cancelImmediately"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
'No refund will be issued for the remaining period.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
</div>
<p class="text-sm text-neutral-500">Cancel your subscription immediately or at the end of the
current billing period.</p>
</div>
</section>
@endif
<div class="text-sm text-neutral-500">
Need help? <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
target="_blank">Contact us.</a>
</div>
@endif
</div>

View file

@ -3,29 +3,26 @@
Subscribe | Coolify
</x-slot>
@if (auth()->user()->isAdminFromSession())
@if (request()->query->get('cancelled'))
<div class="mb-6 rounded-sm alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Something went wrong with your subscription. Please try again or contact
support.</span>
</div>
@endif
<div class="flex gap-2">
<h1>Subscriptions</h1>
</div>
@if ($loading)
<div class="flex gap-2" wire:init="getStripeStatus">
Loading your subscription status...
<div class="flex items-center justify-center min-h-[60vh]" wire:init="getStripeStatus">
<x-loading text="Loading your subscription status..." />
</div>
@else
@if ($isUnpaid)
<div class="mb-6 rounded-sm alert-error">
<span>Your last payment was failed for Coolify Cloud.</span>
</div>
<x-banner :closable="false">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
<span><span class="font-bold text-red-500">Payment Failed.</span> Your last payment for Coolify
Cloud has failed.</span>
</div>
</x-banner>
<div>
<p class="mb-2">Open the following link, navigate to the button and pay your unpaid/past due
subscription.
@ -34,18 +31,20 @@
</div>
@else
@if (config('subscription.provider') === 'stripe')
<div @class([
'pb-4' => $isCancelled,
'pb-10' => !$isCancelled,
])>
@if ($isCancelled)
<div class="alert-error">
<span>It looks like your previous subscription has been cancelled, because you forgot to
pay
the bills.<br />Please subscribe again to continue using Coolify.</span>
@if ($isCancelled)
<x-banner :closable="false">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
<span><span class="font-bold text-red-500">No Active Subscription.</span> Subscribe to
a plan to start using Coolify Cloud.</span>
</div>
@endif
</div>
</x-banner>
@endif
<div @class(['pt-4 pb-4' => $isCancelled, 'pb-10' => !$isCancelled])></div>
<livewire:subscription.pricing-plans />
@endif
@endif

View file

@ -1,162 +1,123 @@
<div x-data="{ selected: 'monthly' }" class="w-full pb-20">
<div class="px-6 mx-auto lg:px-8">
<div class="flex justify-center">
<fieldset
class="grid grid-cols-2 p-1 text-xs font-semibold leading-5 text-center rounded-sm dark:text-white gap-x-1 dark:bg-white/5 bg-black/5">
<legend class="sr-only">Payment frequency</legend>
<label
:class="selected === 'monthly' ?
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
'cursor-pointer rounded-sm px-2.5 py-1'">
<input type="radio" x-on:click="selected = 'monthly'" name="frequency" value="monthly"
class="sr-only">
<span :class="selected === 'monthly' ? 'dark:text-white' : ''">Monthly</span>
</label>
<label
:class="selected === 'yearly' ?
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
'cursor-pointer rounded-sm px-2.5 py-1'">
<input type="radio" x-on:click="selected = 'yearly'" name="frequency" value="annually"
class="sr-only">
<span :class="selected === 'yearly' ? 'dark:text-white' : ''">Annually <span
class="text-xs dark:text-warning text-coollabs">(save ~20%)</span></span>
</label>
</fieldset>
<div x-data="{ selected: 'monthly' }" class="w-full">
{{-- Frequency Toggle --}}
<div class="flex justify-center mb-8">
<fieldset
class="grid grid-cols-2 p-1 text-xs font-semibold leading-5 text-center rounded-sm dark:text-white gap-x-1 dark:bg-white/5 bg-black/5">
<legend class="sr-only">Payment frequency</legend>
<label
:class="selected === 'monthly' ?
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
'cursor-pointer rounded-sm px-2.5 py-1'">
<input type="radio" x-on:click="selected = 'monthly'" name="frequency" value="monthly"
class="sr-only">
<span :class="selected === 'monthly' ? 'dark:text-white' : ''">Monthly</span>
</label>
<label
:class="selected === 'yearly' ?
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
'cursor-pointer rounded-sm px-2.5 py-1'">
<input type="radio" x-on:click="selected = 'yearly'" name="frequency" value="annually"
class="sr-only">
<span :class="selected === 'yearly' ? 'dark:text-white' : ''">Annually <span
class="text-xs dark:text-warning text-coollabs">(save ~20%)</span></span>
</label>
</fieldset>
</div>
<div class="max-w-xl mx-auto">
{{-- Plan Header + Pricing --}}
<h3 id="tier-dynamic" class="text-2xl font-bold dark:text-white">Pay-as-you-go</h3>
<p class="mt-1 text-sm dark:text-neutral-400">Dynamic pricing based on the number of servers you connect.</p>
<div class="mt-4 flex items-baseline gap-x-1">
<span x-show="selected === 'monthly'" x-cloak>
<span class="text-4xl font-bold tracking-tight dark:text-white">$5</span>
<span class="text-sm dark:text-neutral-400">/ mo base</span>
</span>
<span x-show="selected === 'yearly'" x-cloak>
<span class="text-4xl font-bold tracking-tight dark:text-white">$4</span>
<span class="text-sm dark:text-neutral-400">/ mo base</span>
</span>
</div>
<div class="flow-root mt-12">
<div
class="grid grid-cols-1 -mt-16 divide-y divide-neutral-200 dark:divide-coolgray-500 isolate gap-y-16 sm:mx-auto lg:-mx-8 lg:mt-0 lg:max-w-none lg:grid-cols-1 lg:divide-x lg:divide-y-0 xl:-mx-4">
<div class="pt-16 lg:px-8 lg:pt-0 xl:px-14">
<h3 id="tier-dynamic" class="text-4xl font-semibold leading-7 dark:text-white">Pay-as-you-go</h3>
<p class="mt-4 text-sm leading-6 dark:text-neutral-400">
Dynamic pricing based on the number of servers you connect.
</p>
<p class="flex items-baseline mt-6 gap-x-1">
<span x-show="selected === 'monthly'" x-cloak>
<span class="text-4xl font-bold tracking-tight dark:text-white">$5</span>
<span class="text-sm font-semibold leading-6 "> base price</span>
</span>
<p class="mt-1 text-sm dark:text-neutral-400">
<span x-show="selected === 'monthly'" x-cloak>
+ <span class="font-semibold dark:text-white">$3</span> per additional server, billed monthly (+VAT)
</span>
<span x-show="selected === 'yearly'" x-cloak>
+ <span class="font-semibold dark:text-white">$2.7</span> per additional server, billed annually (+VAT)
</span>
</p>
<span x-show="selected === 'yearly'" x-cloak>
<span class="text-4xl font-bold tracking-tight dark:text-white">$4</span>
<span class="text-sm font-semibold leading-6 "> base price</span>
</span>
</p>
<p class="flex items-baseline mb-4 gap-x-1">
<span x-show="selected === 'monthly'" x-cloak>
<span class="text-base font-semibold tracking-tight dark:text-white">$3</span>
<span class="text-sm font-semibold leading-6 "> per additional servers <span
class="font-normal dark:text-white">billed monthly (+VAT)</span></span>
</span>
{{-- Subscribe Button --}}
<div class="flex mt-6">
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-dynamic"
class="w-full" wire:click="subscribeStripe('dynamic-monthly')">
Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-dynamic"
class="w-full" wire:click="subscribeStripe('dynamic-yearly')">
Subscribe
</x-forms.button>
</div>
<span x-show="selected === 'yearly'" x-cloak>
<span class="text-base font-semibold tracking-tight dark:text-white">$2.7</span>
<span class="text-sm font-semibold leading-6 "> per additional servers <span
class="font-normal dark:text-white">billed annually (+VAT)</span></span>
</span>
</p>
<div class="flex items-center pt-6">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-none w-8 h-8 mr-3 dark:text-warning"
fill="currentColor" viewBox="0 0 256 256">
<path
d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z">
</path>
</svg>
{{-- Features --}}
<div class="mt-8 pt-6 border-t dark:border-coolgray-400 border-neutral-200">
<ul role="list" class="space-y-2.5 text-sm">
<li class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<span class="dark:text-neutral-300">Connect <span
class="font-bold dark:text-white">unlimited</span> servers</span>
</li>
<li class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<span class="dark:text-neutral-300">Deploy <span
class="font-bold dark:text-white">unlimited</span> applications per server</span>
</li>
<li class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<span class="dark:text-neutral-300">Free email notifications</span>
</li>
<li class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
<span class="dark:text-neutral-300">Support by email</span>
</li>
<li class="flex items-center gap-2.5 font-bold dark:text-white">
<svg class="w-4 h-4 shrink-0 text-green-500" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
</svg>
+ All Upcoming Features
</li>
</ul>
</div>
<div class="flex flex-col text-sm dark:text-white">
<div>
You need to bring your own servers from any cloud provider (such as <a class="underline"
href="https://coolify.io/hetzner" target="_blank">Hetzner</a>, DigitalOcean, AWS,
etc.)
</div>
<div>
(You can connect your RPi, old laptop, or any other device that runs
the <a class="underline"
href="https://coolify.io/docs/installation#supported-operating-systems"
target="_blank">supported operating systems</a>.)
</div>
</div>
</div>
<div class="flex pt-4 h-14">
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic"
class="w-full" wire:click="subscribeStripe('dynamic-monthly')">
Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic"
class="w-full" wire:click="subscribeStripe('dynamic-yearly')">
Subscribe
</x-forms.button>
</div>
<ul role="list" class="mt-8 space-y-3 text-sm leading-6 dark:text-neutral-400">
<li class="flex">
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
Connect
<span class="px-1 font-bold dark:text-white">unlimited</span> servers
</li>
<li class="flex">
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
Deploy
<span class="px-1 font-bold dark:text-white">unlimited</span> applications per server
</li>
<li class="flex gap-x-3">
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
Free email notifications
</li>
<li class="flex gap-x-3">
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
Support by email
</li>
<li class="flex font-bold dark:text-white gap-x-3">
<svg width="512" height="512" class="flex-none w-5 h-6 text-green-500"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<path
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
</g>
</svg>
+ All Upcoming Features
</li>
<li class="flex dark:text-white gap-x-3">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-none w-5 h-6 text-green-500"
viewBox="0 0 256 256">
<rect width="256" height="256" fill="none" />
<polyline points="32 136 72 136 88 112 120 160 136 136 160 136" fill="none"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="16" />
<path
d="M24,104c0-.67,0-1.33,0-2A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32a54,54,0,0,1,54,54c0,66-104,122-104,122s-42-22.6-72.58-56"
fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="16" />
</svg>
Do you require official support for your self-hosted instance?<a class="underline"
href="https://coolify.io/docs/contact" target="_blank">Contact Us</a>
</li>
</ul>
</div>
</div>
{{-- BYOS Notice + Support --}}
<div class="mt-6 pt-6 border-t dark:border-coolgray-400 border-neutral-200 text-sm dark:text-neutral-400">
<p>You need to bring your own servers from any cloud provider (<a class="underline" href="https://coolify.io/hetzner" target="_blank">Hetzner</a>, DigitalOcean, AWS, etc.) or connect any device running a <a class="underline" href="https://coolify.io/docs/installation#supported-operating-systems" target="_blank">supported OS</a>.</p>
<p class="mt-3">Need official support for your self-hosted instance? <a class="underline dark:text-white" href="https://coolify.io/docs/contact" target="_blank">Contact Us</a></p>
</div>
</div>
</div>

View file

@ -3,6 +3,6 @@
Subscription | Coolify
</x-slot>
<h1>Subscription</h1>
<div class="subtitle">Here you can see and manage your subscription.</div>
<div class="subtitle">Manage your plan, billing, and server limits.</div>
<livewire:subscription.actions />
</div>

View file

@ -0,0 +1,96 @@
<?php
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});
});

View file

@ -0,0 +1,271 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});
});

View file

@ -0,0 +1,85 @@
<?php
use App\Actions\Stripe\ResumeSubscription;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});
});