mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior.
|
||||
- SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f <name> || true` before `docker run --name <name>`).
|
||||
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
|
||||
- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`.
|
||||
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
|
||||
|
||||
## Verify the complete user flow
|
||||
|
||||
@@ -118,6 +118,8 @@ class RefundSubscription
|
||||
send_internal_notification(
|
||||
"CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
|
||||
);
|
||||
|
||||
return ['success' => false, 'error' => 'Refund succeeded, but subscription cancellation failed.'];
|
||||
}
|
||||
|
||||
$subscription->update([
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscription;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Lets a user delete their own account.
|
||||
*
|
||||
* Deletion is refused while the user still owns data that would be lost or
|
||||
* orphaned. The user must resolve each blocker first, the same way team
|
||||
* deletion requires an empty team. With $removeTeamResources, the servers,
|
||||
* resources, projects, and Git sources of teams where the user is the only
|
||||
* member are removed from Coolify instead. Nothing is stopped on the servers.
|
||||
*/
|
||||
class DeleteUserAccount
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function blockers(User $user, bool $removeTeamResources = false): array
|
||||
{
|
||||
if ($user->id === 0) {
|
||||
return ['The root user cannot be deleted.'];
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
|
||||
foreach ($user->teams()->with('members')->get() as $team) {
|
||||
$role = $team->pivot->role;
|
||||
$isAlone = $team->members->count() === 1;
|
||||
|
||||
if ($team->id === 0) {
|
||||
if ($isAlone) {
|
||||
$blockers[] = 'You are the only member of the root team.';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($role !== 'owner') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isAlone) {
|
||||
if (! $removeTeamResources && ! $team->isEmpty()) {
|
||||
$blockers[] = "Delete all projects, servers, and Git sources of the team \"{$team->name}\".";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasReplacementOwner = $team->members->contains(
|
||||
fn (User $member): bool => $member->id !== $user->id && in_array($member->pivot->role, ['owner', 'admin'], true)
|
||||
);
|
||||
|
||||
if (! $hasReplacementOwner) {
|
||||
$blockers[] = "Make another member of the team \"{$team->name}\" an admin or owner.";
|
||||
}
|
||||
}
|
||||
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
public function handle(User $user, bool $removeTeamResources = false): void
|
||||
{
|
||||
if ($blockers = $this->blockers($user, $removeTeamResources)) {
|
||||
throw new RuntimeException(implode(' ', $blockers));
|
||||
}
|
||||
|
||||
foreach ($this->subscriptionTeams($user) as $team) {
|
||||
if (! CancelSubscription::cancelById($team->subscription->stripe_subscription_id)) {
|
||||
throw new RuntimeException("Could not cancel the subscription of the team \"{$team->name}\". Your account was not deleted. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
app(AvatarStorageService::class)->delete($user);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $removeTeamResources): void {
|
||||
$user = User::query()->lockForUpdate()->findOrFail($user->id);
|
||||
|
||||
if ($blockers = $this->blockers($user, $removeTeamResources)) {
|
||||
throw new RuntimeException(implode(' ', $blockers));
|
||||
}
|
||||
|
||||
if ($removeTeamResources) {
|
||||
$this->singleMemberTeams($user)->each(fn (Team $team) => $this->removeTeamResources($team));
|
||||
}
|
||||
|
||||
// The subscription row of a deleted team would point to a missing
|
||||
// team, and later Stripe webhooks for its customer would fail.
|
||||
Subscription::query()->whereIn('team_id', $this->singleMemberTeams($user)->pluck('id'))->delete();
|
||||
|
||||
// Deletes the user's single-member teams, promotes an admin where
|
||||
// the user was the only owner, and removes the user from the rest.
|
||||
(new DeleteUserTeams($user))->execute();
|
||||
|
||||
// team_user has no foreign keys, so also remove the root team
|
||||
// membership and the rows of teams deleted above.
|
||||
$user->teams()->detach();
|
||||
|
||||
$user->fresh()->delete();
|
||||
|
||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function confirmationActions(User $user): array
|
||||
{
|
||||
return $this->subscriptionTeams($user)
|
||||
->map(fn (Team $team): string => "The subscription of the team \"{$team->name}\" will be cancelled immediately. This is required.")
|
||||
->push('Your account will be permanently deleted from Coolify.')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function singleMemberTeams(User $user): Collection
|
||||
{
|
||||
return $user->teams()
|
||||
->where('teams.id', '!=', 0)
|
||||
->wherePivot('role', 'owner')
|
||||
->withCount('members')
|
||||
->get()
|
||||
->filter(fn (Team $team): bool => $team->members_count === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the team's data from Coolify. Git sources are removed when the
|
||||
* team itself is deleted.
|
||||
*/
|
||||
private function removeTeamResources(Team $team): void
|
||||
{
|
||||
$this->forgetGithubRunners($team);
|
||||
|
||||
foreach ($team->servers()->get() as $server) {
|
||||
foreach ($server->definedResources() as $resource) {
|
||||
$resource->forceDelete();
|
||||
}
|
||||
$server->forceDelete();
|
||||
}
|
||||
|
||||
foreach ($team->projects()->get() as $project) {
|
||||
$project->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the team's GitHub Actions runner records so that busy runners do
|
||||
* not block the removal of its servers and Git sources. The runners keep
|
||||
* running on the servers; Coolify only stops tracking them.
|
||||
*/
|
||||
private function forgetGithubRunners(Team $team): void
|
||||
{
|
||||
$githubAppIds = GithubApp::query()->where('team_id', $team->id)->where('is_system_wide', false)->pluck('id');
|
||||
$serverIds = $team->servers()->pluck('id');
|
||||
|
||||
$belongsToTeam = fn ($query) => $query->whereIn('github_app_id', $githubAppIds)->orWhereIn('server_id', $serverIds);
|
||||
|
||||
GithubRunnerExecution::query()->where($belongsToTeam)->delete();
|
||||
GithubRunnerConfig::query()->where($belongsToTeam)->delete();
|
||||
}
|
||||
|
||||
private function subscriptionTeams(User $user): Collection
|
||||
{
|
||||
if (! isCloud()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $user->teams()
|
||||
->where('teams.id', '!=', 0)
|
||||
->wherePivot('role', 'owner')
|
||||
->with('subscription')
|
||||
->get()
|
||||
->filter(fn (Team $team): bool => filled($team->subscription?->stripe_subscription_id));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Profile;
|
||||
|
||||
use App\Actions\User\DeleteUserAccount;
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -347,6 +348,28 @@ class Index extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAccount(string $password, array $selectedActions = []): mixed
|
||||
{
|
||||
try {
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
app(DeleteUserAccount::class)->handle(Auth::user());
|
||||
auditLog('ui.user.account_deleted', ['resource' => 'user']);
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
|
||||
$this->redirect(route('login'));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function providerLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
@@ -368,6 +391,9 @@ class Index extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.index');
|
||||
return view('livewire.profile.index', [
|
||||
'accountDeletionBlockers' => app(DeleteUserAccount::class)->blockers(Auth::user()),
|
||||
'accountDeletionActions' => app(DeleteUserAccount::class)->confirmationActions(Auth::user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Actions\Stripe\ResumeSubscription;
|
||||
use App\Actions\Stripe\UpdateSubscriptionQuantity;
|
||||
use App\Actions\User\DeleteUserAccount;
|
||||
use App\Models\Team;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
use Stripe\StripeClient;
|
||||
@@ -34,6 +36,10 @@ class Actions extends Component
|
||||
|
||||
public bool $refundLatestPayment = false;
|
||||
|
||||
public bool $deleteAccount = false;
|
||||
|
||||
public bool $canDeleteAccount = false;
|
||||
|
||||
public string $billingInterval = 'monthly';
|
||||
|
||||
public ?string $nextBillingDate = null;
|
||||
@@ -43,6 +49,7 @@ class Actions extends Component
|
||||
$this->server_limits = Team::serverLimit();
|
||||
$this->quantity = (int) $this->server_limits;
|
||||
$this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly';
|
||||
$this->canDeleteAccount = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true) === [];
|
||||
}
|
||||
|
||||
public function loadPricePreview(int $quantity): void
|
||||
@@ -128,62 +135,26 @@ class Actions extends Component
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
if (in_array('refundLatestPayment', $selectedActions, true)) {
|
||||
// Eligibility is re-validated server-side inside RefundSubscription::execute()
|
||||
$result = app(RefundSubscription::class)->execute(currentTeam());
|
||||
$deleteAccount = in_array('deleteAccount', $selectedActions, true);
|
||||
if ($deleteAccount && ($blockers = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true))) {
|
||||
return implode(' ', $blockers);
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('refunded');
|
||||
$this->dispatch('success', 'Subscription refunded and cancelled 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>.');
|
||||
$refund = in_array('refundLatestPayment', $selectedActions, true);
|
||||
if (! $this->cancelSubscriptionNow($refund)) {
|
||||
$this->dispatch('error', 'Something went wrong with the '.($refund ? 'refund' : 'cancellation').'. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$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;
|
||||
if ($deleteAccount) {
|
||||
return $this->deleteAccountAfterCancellation();
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = app(StripeClient::class);
|
||||
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
$this->dispatch('success', $refund ? 'Subscription refunded and cancelled successfully.' : 'Subscription cancelled successfully.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
$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();
|
||||
|
||||
$this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]);
|
||||
|
||||
\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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancelAtPeriodEnd(string $password): bool|string
|
||||
@@ -222,6 +193,80 @@ class Actions extends Component
|
||||
return true;
|
||||
}
|
||||
|
||||
private function cancelSubscriptionNow(bool $refund): bool
|
||||
{
|
||||
$team = currentTeam();
|
||||
|
||||
if ($refund) {
|
||||
// Eligibility is re-validated server-side inside RefundSubscription::execute()
|
||||
$result = app(RefundSubscription::class)->execute($team);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->auditSubscriptionChange('refunded');
|
||||
}
|
||||
|
||||
return $result['success'];
|
||||
}
|
||||
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// subscriptionEnded() clears the ID on this model, so keep it for the log.
|
||||
$subscriptionId = $subscription->stripe_subscription_id;
|
||||
|
||||
try {
|
||||
$stripe = app(StripeClient::class);
|
||||
$stripe->subscriptions->cancel($subscriptionId);
|
||||
|
||||
$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();
|
||||
|
||||
$this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]);
|
||||
|
||||
\Log::info("Subscription {$subscriptionId} cancelled immediately for team {$team->name}");
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAccountAfterCancellation(): bool
|
||||
{
|
||||
try {
|
||||
app(DeleteUserAccount::class)->handle(Auth::user(), removeTeamResources: true);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$this->dispatch('error', 'Your subscription was cancelled, but your account could not be deleted. Delete it from your profile page or <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
auditLog('ui.user.account_deleted', ['resource' => 'user']);
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
|
||||
$this->redirect(route('login'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a user-triggered subscription change for the current team.
|
||||
*
|
||||
|
||||
@@ -72,14 +72,18 @@
|
||||
dispatchEventMessage: @js($dispatchEventMessage),
|
||||
disableTwoStepConfirmation: @js($disableTwoStepConfirmation),
|
||||
skipPasswordConfirmation: @js($skipPasswordConfirmation),
|
||||
resetModal() {
|
||||
// A successful submit already re-rendered the component. Refreshing again
|
||||
// after it can hit an ended session, for example after an account deletion.
|
||||
resetModal(refresh = true) {
|
||||
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();
|
||||
if (refresh) {
|
||||
$wire.$refresh();
|
||||
}
|
||||
},
|
||||
step1ButtonText: @js($step1ButtonText),
|
||||
step2ButtonText: @js($effectiveStep2ButtonText),
|
||||
@@ -332,7 +336,7 @@
|
||||
$nextTick(() => {
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
resetModal();
|
||||
resetModal(result !== true);
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
modalOpen = true;
|
||||
@@ -390,7 +394,7 @@
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
if (result === true) {
|
||||
resetModal();
|
||||
resetModal(false);
|
||||
} else {
|
||||
modalOpen = true;
|
||||
passwordError = result;
|
||||
|
||||
@@ -300,6 +300,51 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-header">
|
||||
<div>
|
||||
<h2>Danger zone</h2>
|
||||
<p>Destructive actions for your account cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="application-settings-section-body">
|
||||
<x-danger-zone title="Delete account">
|
||||
@if ($accountDeletionBlockers === [])
|
||||
<p>
|
||||
Permanently delete your account from Coolify. This action cannot be undone.
|
||||
</p>
|
||||
<ul class="space-y-1 text-xs">
|
||||
<li>• Teams where you are the only member are deleted.</li>
|
||||
<li>• You are removed from all other teams.</li>
|
||||
<li>• Your API tokens and sessions are revoked.</li>
|
||||
</ul>
|
||||
@else
|
||||
<p>Before you can delete your account:</p>
|
||||
<ul class="space-y-1">
|
||||
@foreach ($accountDeletionBlockers as $blocker)
|
||||
<li>• {{ $blocker }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
<x-slot:action>
|
||||
@if ($accountDeletionBlockers === [])
|
||||
<x-modal-confirmation title="Confirm Account Deletion?" buttonTitle="Delete account"
|
||||
isErrorButton submitAction="deleteAccount"
|
||||
:actions="$accountDeletionActions"
|
||||
confirmationText="{{ $email }}"
|
||||
confirmationLabel="Enter your email address to confirm permanent deletion"
|
||||
shortConfirmationLabel="Email" step3ButtonText="Permanently Delete" />
|
||||
@else
|
||||
<x-forms.button isError disabled
|
||||
tooltip="Resolve the requirements shown before deleting your account.">
|
||||
Delete account
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</x-slot:action>
|
||||
</x-danger-zone>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (session()->has('errors'))
|
||||
<x-callout type="danger" title="Profile update failed">
|
||||
Something went wrong. Please review the fields and try again.
|
||||
|
||||
@@ -33,242 +33,248 @@
|
||||
@keydown.escape.window="if (showModal) { closeAdjust(); }">
|
||||
@if (subscriptionProvider() === 'stripe')
|
||||
{{-- Plan Overview --}}
|
||||
<x-application.settings-section title="Plan overview"
|
||||
description="Review billing status and the number of servers included in this plan.">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm">
|
||||
<span class="text-neutral-500">Plan:</span>
|
||||
<span class="dark:text-warning font-medium">
|
||||
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
|
||||
Pay-as-you-go
|
||||
@else
|
||||
{{ data_get(currentTeam(), 'subscription')->type() }}
|
||||
@endif
|
||||
</span>
|
||||
<span class="text-neutral-500">· {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }}</span>
|
||||
<span class="text-neutral-500">·</span>
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
<span class="text-neutral-500">Active servers:</span>
|
||||
<span class="font-medium {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">{{ currentTeam()->servers->count() }}</span>
|
||||
<span class="text-neutral-500">/</span>
|
||||
<span class="font-medium dark:text-white" x-text="current"></span>
|
||||
<span class="text-neutral-500">paid</span>
|
||||
</span>
|
||||
<x-forms.button isHighlighted @click="openAdjust()">Adjust</x-forms.button>
|
||||
</div>
|
||||
<div class="text-sm text-neutral-500">
|
||||
@if ($refundCheckLoading)
|
||||
<x-loading text="Loading..." />
|
||||
@elseif ($nextBillingDate)
|
||||
<x-application.settings-section title="Plan and billing">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-lg! font-semibold!">
|
||||
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
|
||||
Pay-as-you-go
|
||||
@else
|
||||
{{ data_get(currentTeam(), 'subscription')->type() }}
|
||||
@endif
|
||||
</h2>
|
||||
<span class="text-sm text-neutral-500">{{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }} billing</span>
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
Cancels on <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
|
||||
<x-status-badge type="warning" label="Cancelling at end of period" />
|
||||
@else
|
||||
Next billing <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
|
||||
<x-status-badge type="success" label="Active" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
<span class="text-neutral-500">Active servers:</span>
|
||||
<span class="font-medium {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">{{ currentTeam()->servers->count() }}</span>
|
||||
<span class="text-neutral-500">/</span>
|
||||
<span class="font-medium dark:text-white" x-text="current"></span>
|
||||
<span class="text-neutral-500">paid</span>
|
||||
</span>
|
||||
@if ($refundCheckLoading)
|
||||
<span class="text-neutral-500">·</span>
|
||||
<x-loading compact text="Loading..." />
|
||||
@elseif ($nextBillingDate)
|
||||
<span class="text-neutral-500">·</span>
|
||||
<span class="text-neutral-500">
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
Cancels on <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
|
||||
@else
|
||||
Next billing <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (currentTeam()->serverOverflow())
|
||||
<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
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-forms.button isHighlighted @click="openAdjust()">Adjust server limit</x-forms.button>
|
||||
<x-forms.button class="gap-2" wire:click="stripeCustomerPortal">
|
||||
<x-reicon name="subscription" class="size-3.5" />
|
||||
Open billing portal
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
{{-- Adjust Server Limit Modal --}}
|
||||
<template x-teleport="body" wire:ignore>
|
||||
<div x-show="showModal"
|
||||
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
|
||||
<div x-show="showModal" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"
|
||||
@click="closeAdjust()">
|
||||
</div>
|
||||
<div x-show="showModal" x-trap.inert.noscroll="showModal"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="application-settings-section application-settings-form relative flex max-h-[calc(100vh-2rem)] w-full max-w-xl flex-col shadow-modal">
|
||||
<div class="flex min-h-11 shrink-0 items-center justify-between px-4">
|
||||
<h3 class="text-[13px]! font-semibold!">Adjust server limit</h3>
|
||||
<button type="button" @click="closeAdjust()"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="application-settings-section-body relative w-auto space-y-4 overflow-y-auto p-4"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
{{-- Server count input --}}
|
||||
<div>
|
||||
<label class="mb-1.5 block">Paid servers</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="number" min="{{ $minServerLimit }}" max="{{ $maxServerLimit }}" step="1"
|
||||
x-model.number="qty"
|
||||
@input="preview = null"
|
||||
@change="qty = Math.min({{ $maxServerLimit }}, Math.max({{ $minServerLimit }}, qty || {{ $minServerLimit }}))"
|
||||
class="h-8! w-24 rounded-lg! border-neutral-200! bg-white! px-2! py-0! text-center! text-[13px]! font-semibold! shadow-none! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.04]! dark:text-fg!">
|
||||
<x-forms.button
|
||||
isHighlighted
|
||||
x-bind:disabled="!hasChanged || loading"
|
||||
@click="fetchPreview()">
|
||||
Calculate Price
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loading --}}
|
||||
<div x-show="loading" x-cloak>
|
||||
<x-loading text="Loading price preview..." />
|
||||
</div>
|
||||
|
||||
{{-- Price Preview --}}
|
||||
<div class="space-y-4" x-show="!loading && hasPreview" x-cloak>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold">
|
||||
<span class="dark:text-white">Prorated charge</span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview?.due_now)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">
|
||||
Next billing cycle
|
||||
@if ($nextBillingDate)
|
||||
<span class="normal-case font-normal">· {{ $nextBillingDate }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between gap-6 text-sm">
|
||||
<span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_subtotal)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
|
||||
<span class="text-neutral-500" x-text="preview?.tax_description"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<span class="dark:text-white">Total / <span x-text="preview?.billing_interval === 'year' ? 'year' : 'month'">month</span></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Update Button with Confirmation --}}
|
||||
<x-modal-confirmation
|
||||
title="Confirm Server Limit Update"
|
||||
buttonTitle="Update Server Limit"
|
||||
submitAction="updateQuantity"
|
||||
:confirmWithText="false"
|
||||
:confirmWithPassword="false"
|
||||
:actions="[
|
||||
'Your server limit will be updated immediately.',
|
||||
'The prorated amount will be invoiced and charged now.',
|
||||
]"
|
||||
warningMessage="This will update your subscription and charge the prorated amount to your payment method."
|
||||
step2ButtonText="Confirm & Pay">
|
||||
<x-slot:content>
|
||||
<x-forms.button class="w-full" @click="$wire.set('quantity', qty)">
|
||||
Update Server Limit
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
|
||||
{{-- Reduction Warning --}}
|
||||
<div x-show="isReduction" x-cloak>
|
||||
<x-callout type="danger" title="Warning">
|
||||
Reducing below your active server count will deactivate excess servers.
|
||||
</x-callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Manage subscription">
|
||||
{{-- Cancel Subscription --}}
|
||||
<section aria-labelledby="subscription-cancellation" class="flex flex-col gap-3">
|
||||
<div class="space-y-1">
|
||||
<h3 id="subscription-cancellation" class="text-sm! font-medium!">Cancel subscription</h3>
|
||||
<p class="text-sm text-neutral-500">Keep access until the end of your billing period, or cancel immediately.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
|
||||
@else
|
||||
<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" />
|
||||
@php
|
||||
$deleteAccountCheckbox = [
|
||||
'id' => 'deleteAccount',
|
||||
'label' => 'Delete my account. Servers, projects, and Git sources of teams where I am the only member are removed from Coolify. Nothing is stopped on the servers.',
|
||||
];
|
||||
@endphp
|
||||
@if ($isRefundEligible)
|
||||
<div wire:key="cancel-immediately-refundable">
|
||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||
isErrorButton submitAction="cancelImmediately"
|
||||
:checkboxes="[
|
||||
[
|
||||
'id' => 'refundLatestPayment',
|
||||
'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
|
||||
'default_warning' => 'No refund will be issued for the remaining period.',
|
||||
],
|
||||
...($canDeleteAccount ? [$deleteAccountCheckbox] : []),
|
||||
]"
|
||||
:actions="[
|
||||
'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="Permanently Cancel" />
|
||||
</div>
|
||||
@else
|
||||
<div wire:key="cancel-immediately-standard">
|
||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||
isErrorButton submitAction="cancelImmediately"
|
||||
:checkboxes="$canDeleteAccount ? [$deleteAccountCheckbox] : []"
|
||||
: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>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (currentTeam()->serverOverflow())
|
||||
<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
|
||||
|
||||
{{-- Adjust Server Limit Modal --}}
|
||||
<template x-teleport="body" wire:ignore>
|
||||
<div x-show="showModal"
|
||||
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
|
||||
<div x-show="showModal" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"
|
||||
@click="closeAdjust()">
|
||||
</div>
|
||||
<div x-show="showModal" x-trap.inert.noscroll="showModal"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="application-settings-section application-settings-form relative flex max-h-[calc(100vh-2rem)] w-full max-w-xl flex-col shadow-modal">
|
||||
<div class="flex min-h-11 shrink-0 items-center justify-between px-4">
|
||||
<h3 class="text-[13px]! font-semibold!">Adjust server limit</h3>
|
||||
<button type="button" @click="closeAdjust()"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="application-settings-section-body relative w-auto space-y-4 overflow-y-auto p-4"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
{{-- Server count input --}}
|
||||
<div>
|
||||
<label class="mb-1.5 block">Paid servers</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="number" min="{{ $minServerLimit }}" max="{{ $maxServerLimit }}" step="1"
|
||||
x-model.number="qty"
|
||||
@input="preview = null"
|
||||
@change="qty = Math.min({{ $maxServerLimit }}, Math.max({{ $minServerLimit }}, qty || {{ $minServerLimit }}))"
|
||||
class="h-8! w-24 rounded-lg! border-neutral-200! bg-white! px-2! py-0! text-center! text-[13px]! font-semibold! shadow-none! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.04]! dark:text-fg!">
|
||||
<x-forms.button
|
||||
isHighlighted
|
||||
x-bind:disabled="!hasChanged || loading"
|
||||
@click="fetchPreview()">
|
||||
Calculate Price
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loading --}}
|
||||
<div x-show="loading" x-cloak>
|
||||
<x-loading text="Loading price preview..." />
|
||||
</div>
|
||||
|
||||
{{-- Price Preview --}}
|
||||
<div class="space-y-4" x-show="!loading && hasPreview" x-cloak>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Due now</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold">
|
||||
<span class="dark:text-white">Prorated charge</span>
|
||||
<span class="dark:text-warning" x-text="fmt(preview?.due_now)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">
|
||||
Next billing cycle
|
||||
@if ($nextBillingDate)
|
||||
<span class="normal-case font-normal">· {{ $nextBillingDate }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between gap-6 text-sm">
|
||||
<span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_subtotal)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm" x-show="preview?.tax_description" x-cloak>
|
||||
<span class="text-neutral-500" x-text="preview?.tax_description"></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<span class="dark:text-white">Total / <span x-text="preview?.billing_interval === 'year' ? 'year' : 'month'">month</span></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Update Button with Confirmation --}}
|
||||
<x-modal-confirmation
|
||||
title="Confirm Server Limit Update"
|
||||
buttonTitle="Update Server Limit"
|
||||
submitAction="updateQuantity"
|
||||
:confirmWithText="false"
|
||||
:confirmWithPassword="false"
|
||||
:actions="[
|
||||
'Your server limit will be updated immediately.',
|
||||
'The prorated amount will be invoiced and charged now.',
|
||||
]"
|
||||
warningMessage="This will update your subscription and charge the prorated amount to your payment method."
|
||||
step2ButtonText="Confirm & Pay">
|
||||
<x-slot:content>
|
||||
<x-forms.button class="w-full" @click="$wire.set('quantity', qty)">
|
||||
Update Server Limit
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
|
||||
{{-- Reduction Warning --}}
|
||||
<div x-show="isReduction" x-cloak>
|
||||
<x-callout type="danger" title="Warning">
|
||||
Reducing below your active server count will deactivate excess servers.
|
||||
</x-callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Manage Subscription --}}
|
||||
<x-application.settings-section title="Billing"
|
||||
description="Open the hosted billing portal to update invoices and payment methods.">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
|
||||
<x-reicon name="subscription" class="size-3.5" />
|
||||
Open billing portal
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Cancel Subscription --}}
|
||||
<x-application.settings-section title="Cancellation"
|
||||
description="End the plan at the next billing date or cancel it immediately.">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
|
||||
@else
|
||||
<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" />
|
||||
@if ($isRefundEligible)
|
||||
<div wire:key="cancel-immediately-refundable">
|
||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||
isErrorButton submitAction="cancelImmediately"
|
||||
:checkboxes="[
|
||||
[
|
||||
'id' => 'refundLatestPayment',
|
||||
'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
|
||||
'default_warning' => 'No refund will be issued for the remaining period.',
|
||||
],
|
||||
]"
|
||||
:actions="[
|
||||
'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="Permanently Cancel" />
|
||||
</div>
|
||||
@else
|
||||
<div wire:key="cancel-immediately-standard">
|
||||
<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>
|
||||
@endif
|
||||
<p class="text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing period.</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<p class="mt-2 text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing period.</p>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</section>
|
||||
|
||||
{{-- Refund --}}
|
||||
<x-application.settings-section title="Refund"
|
||||
description="Request a refund when this team is still inside the eligibility window.">
|
||||
@if ($refundCheckLoading || $isRefundEligible)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($refundCheckLoading)
|
||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
||||
@else
|
||||
{{-- Refund --}}
|
||||
<section aria-labelledby="subscription-refund" class="mt-5 flex flex-col gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
||||
<h3 id="subscription-refund" class="text-sm! font-medium!">Refund</h3>
|
||||
@if (! $refundCheckLoading && $isRefundEligible)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
||||
isErrorButton submitAction="refundSubscription"
|
||||
:actions="[
|
||||
@@ -278,20 +284,21 @@
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
||||
step2ButtonText="Confirm Refund & Cancel" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="text-sm text-neutral-500">
|
||||
@if ($refundCheckLoading)
|
||||
<x-loading compact text="Checking refund eligibility..." />
|
||||
@elseif ($isRefundEligible)
|
||||
Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
||||
@elseif ($refundAlreadyUsed)
|
||||
Refund already processed. Each team is eligible for one refund only.
|
||||
@else
|
||||
Not eligible for a refund.
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
@if ($refundCheckLoading)
|
||||
Checking refund eligibility...
|
||||
@elseif ($isRefundEligible)
|
||||
Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
||||
@elseif ($refundAlreadyUsed)
|
||||
Refund already processed. Each team is eligible for one refund only.
|
||||
@else
|
||||
Not eligible for a refund.
|
||||
@endif
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</x-application.settings-section>
|
||||
|
||||
<div class="text-sm text-neutral-500">
|
||||
|
||||
@@ -247,6 +247,26 @@ describe('subscription changes', function () {
|
||||
expect(instanceSettingsAuditEvents('ui.subscription.refunded')->sole()->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('immediate refund is audited only when it succeeds', function (bool $success) {
|
||||
$subscription = createInstanceSettingsAuditSubscription($this->team);
|
||||
$refund = Mockery::mock(RefundSubscription::class);
|
||||
$refund->shouldReceive('execute')->once()->andReturn(['success' => $success, 'error' => null]);
|
||||
app()->instance(RefundSubscription::class, $refund);
|
||||
|
||||
Livewire::test(SubscriptionActions::class)
|
||||
->call('cancelImmediately', 'password', ['refundLatestPayment'])
|
||||
->assertDispatched($success ? 'success' : 'error');
|
||||
|
||||
$events = instanceSettingsAuditEvents('ui.subscription.refunded');
|
||||
|
||||
if ($success) {
|
||||
expect($events->sole()->team_id)->toBe($this->team->id)
|
||||
->and($events->sole()->metadata['subscription_id'])->toBe($subscription->id);
|
||||
} else {
|
||||
expect($events)->toBeEmpty();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
test('server limit change is audited', function () {
|
||||
Bus::fake();
|
||||
createInstanceSettingsAuditSubscription($this->team);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\User\DeleteUserAccount;
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\CleanupGithubRunnerJob;
|
||||
use App\Livewire\Profile\Index as ProfileIndex;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
$this->rootUser = User::factory()->create(['id' => 0]);
|
||||
});
|
||||
|
||||
function deleteAccountUser(): User
|
||||
{
|
||||
$user = User::factory()->create(['name' => 'Delete Me', 'password' => bcrypt('password')]);
|
||||
test()->actingAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('deletes the account and the personal team, then logs the user out', function () {
|
||||
$user = deleteAccountUser();
|
||||
$personalTeam = $user->teams()->first();
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->call('deleteAccount', 'password')
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($personalTeam->id))->toBeNull()
|
||||
->and(DB::table('team_user')->where('user_id', $user->id)->exists())->toBeFalse()
|
||||
->and(auth()->check())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not delete the account with a wrong password', function () {
|
||||
$user = deleteAccountUser();
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->call('deleteAccount', 'wrong-password')
|
||||
->assertHasErrors('password');
|
||||
|
||||
expect(User::find($user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('never deletes the root user', function () {
|
||||
expect(app(DeleteUserAccount::class)->blockers($this->rootUser))
|
||||
->toBe(['The root user cannot be deleted.']);
|
||||
|
||||
expect(fn () => app(DeleteUserAccount::class)->handle($this->rootUser))
|
||||
->toThrow(RuntimeException::class, 'The root user cannot be deleted.');
|
||||
|
||||
expect(User::find(0))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks deletion while a single-member team still has projects', function () {
|
||||
$user = deleteAccountUser();
|
||||
$personalTeam = $user->teams()->first();
|
||||
Project::factory()->create(['team_id' => $personalTeam->id]);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertSee('Delete all projects, servers, and Git sources')
|
||||
->call('deleteAccount', 'password');
|
||||
|
||||
expect(User::find($user->id))->not->toBeNull()
|
||||
->and(Team::find($personalTeam->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('transfers team ownership to an admin and keeps the shared team', function () {
|
||||
$user = deleteAccountUser();
|
||||
$admin = User::factory()->create();
|
||||
$sharedTeam = Team::factory()->create();
|
||||
$sharedTeam->members()->attach($user->id, ['role' => 'owner']);
|
||||
$sharedTeam->members()->attach($admin->id, ['role' => 'admin']);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user);
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($sharedTeam->id))->not->toBeNull()
|
||||
->and($admin->roleInTeam($sharedTeam->id))->toBe('owner');
|
||||
});
|
||||
|
||||
it('blocks deletion when a shared team has no other owner or admin', function () {
|
||||
$user = deleteAccountUser();
|
||||
$member = User::factory()->create();
|
||||
$sharedTeam = Team::factory()->create(['name' => 'Shared']);
|
||||
$sharedTeam->members()->attach($user->id, ['role' => 'owner']);
|
||||
$sharedTeam->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
expect(app(DeleteUserAccount::class)->blockers($user))
|
||||
->toBe(['Make another member of the team "Shared" an admin or owner.']);
|
||||
});
|
||||
|
||||
it('removes a member from other teams without changing those teams', function () {
|
||||
$owner = User::factory()->create();
|
||||
$user = deleteAccountUser();
|
||||
$sharedTeam = Team::factory()->create();
|
||||
$sharedTeam->members()->attach($owner->id, ['role' => 'owner']);
|
||||
$sharedTeam->members()->attach($user->id, ['role' => 'member']);
|
||||
$this->rootTeam->members()->attach($user->id, ['role' => 'member']);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user);
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and($sharedTeam->members()->pluck('users.id')->all())->toBe([$owner->id])
|
||||
->and($this->rootTeam->members()->pluck('users.id')->all())->toBe([$this->rootUser->id]);
|
||||
});
|
||||
|
||||
it('cancels the subscription immediately before deleting the account on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$personalTeam = $user->teams()->first();
|
||||
$personalTeam->update(['name' => 'Paid']);
|
||||
Subscription::create([
|
||||
'team_id' => $personalTeam->id,
|
||||
'stripe_subscription_id' => 'sub_123',
|
||||
'stripe_customer_id' => 'cus_123',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_123', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'canceled']));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertSee('will be cancelled immediately. This is required.')
|
||||
->call('deleteAccount', 'password', [])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($personalTeam->id))->toBeNull()
|
||||
->and(Subscription::where('stripe_subscription_id', 'sub_123')->exists())->toBeFalse()
|
||||
->and(Subscription::where('team_id', $personalTeam->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the account and subscription when immediate cancellation fails', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_failed', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_failed', [])->andThrow(new RuntimeException('Stripe unavailable'));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class);
|
||||
expect(User::find($user->id))->not->toBeNull()
|
||||
->and(Team::find($team->id))->not->toBeNull()
|
||||
->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not cancel subscriptions before resolving account deletion blockers', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
Project::factory()->create(['team_id' => $team->id]);
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_blocked', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldNotReceive('cancel');
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class);
|
||||
expect(User::find($user->id))->not->toBeNull()
|
||||
->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves subscriptions of other teams and teams where the user is not an owner', function (string $role) {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$owner = User::factory()->create();
|
||||
$team = $owner->teams()->first();
|
||||
$user = deleteAccountUser();
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_other', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldNotReceive('cancel');
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user);
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy();
|
||||
})->with(['member', 'admin']);
|
||||
|
||||
it('does not cancel a subscription with an incorrect password', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$subscription = Subscription::create(['team_id' => $user->teams()->first()->id, 'stripe_subscription_id' => 'sub_password', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldNotReceive('cancel');
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
Livewire::test(ProfileIndex::class)->call('deleteAccount', 'wrong-password')->assertHasErrors('password');
|
||||
expect(User::find($user->id))->not->toBeNull()
|
||||
->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
it('cancels a past due subscription and preserves a transferred team', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$admin = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$team->members()->attach($admin->id, ['role' => 'admin']);
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_past_due', 'stripe_invoice_paid' => false, 'stripe_past_due' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_past_due', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'canceled']));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user);
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($team->id))->not->toBeNull()
|
||||
->and($admin->roleInTeam($team->id))->toBe('owner')
|
||||
->and($subscription->fresh()->stripe_subscription_id)->toBeNull()
|
||||
->and($subscription->fresh()->stripe_past_due)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not cancel the root team subscription when another owner deletes their account', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$this->rootTeam->members()->attach($user->id, ['role' => 'owner']);
|
||||
$subscription = Subscription::create(['team_id' => 0, 'stripe_subscription_id' => 'sub_root', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldNotReceive('cancel');
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user);
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy()
|
||||
->and(Team::find(0))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('removes the data of single-member teams only when asked and keeps shared team data', function () {
|
||||
$user = deleteAccountUser();
|
||||
$personalTeam = $user->teams()->first();
|
||||
$personalProject = Project::factory()->create(['team_id' => $personalTeam->id]);
|
||||
$personalKey = PrivateKey::factory()->create(['team_id' => $personalTeam->id]);
|
||||
$personalServer = Server::factory()->create(['team_id' => $personalTeam->id, 'private_key_id' => $personalKey->id]);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$sharedTeam = Team::factory()->create();
|
||||
$sharedTeam->members()->attach($user->id, ['role' => 'owner']);
|
||||
$sharedTeam->members()->attach($admin->id, ['role' => 'admin']);
|
||||
$sharedProject = Project::factory()->create(['team_id' => $sharedTeam->id]);
|
||||
$sharedKey = PrivateKey::factory()->create(['team_id' => $sharedTeam->id]);
|
||||
$sharedServer = Server::factory()->create(['team_id' => $sharedTeam->id, 'private_key_id' => $sharedKey->id]);
|
||||
|
||||
expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class);
|
||||
expect(Project::find($personalProject->id))->not->toBeNull();
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user, removeTeamResources: true);
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($personalTeam->id))->toBeNull()
|
||||
->and(Project::find($personalProject->id))->toBeNull()
|
||||
->and(Server::withTrashed()->find($personalServer->id))->toBeNull()
|
||||
->and(Project::find($sharedProject->id))->not->toBeNull()
|
||||
->and(Server::find($sharedServer->id))->not->toBeNull()
|
||||
->and($admin->roleInTeam($sharedTeam->id))->toBe('owner');
|
||||
});
|
||||
|
||||
it('forgets busy github runners without cleanup instead of blocking the removal of team data', function () {
|
||||
Bus::fake();
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Runner App',
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
$config = GithubRunnerConfig::create(['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coolify'], 'is_enabled' => true]);
|
||||
$running = GithubRunnerExecution::create(['github_app_id' => $githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running]);
|
||||
$queued = GithubRunnerExecution::create(['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Queued]);
|
||||
|
||||
app(DeleteUserAccount::class)->handle($user, removeTeamResources: true);
|
||||
|
||||
Bus::assertNotDispatched(CleanupGithubRunnerJob::class);
|
||||
Bus::assertNotDispatchedSync(CleanupGithubRunnerJob::class);
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(GithubApp::find($githubApp->id))->toBeNull()
|
||||
->and(Server::withTrashed()->find($server->id))->toBeNull()
|
||||
->and(GithubRunnerExecution::find($running->id))->toBeNull()
|
||||
->and(GithubRunnerExecution::find($queued->id))->toBeNull()
|
||||
->and(GithubRunnerConfig::find($config->id))->toBeNull();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Profile\Index as ProfileIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@@ -8,6 +9,7 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->user = User::factory()->create(['name' => 'Profile User', 'email' => 'old@example.com']);
|
||||
$this->user->forceFill([
|
||||
'pending_email' => 'new@example.com',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Profile\Index as ProfileIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthIdentity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -9,6 +10,10 @@ use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
it('shows when the profile user signed in with sso', function () {
|
||||
$user = User::factory()->create(['name' => 'Profile User']);
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('execute', function () {
|
||||
expect($result['error'])->toContain('No payment intent');
|
||||
});
|
||||
|
||||
test('records refund and proceeds when cancel fails', function () {
|
||||
test('records refund and preserves the subscription when cancel fails', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
@@ -327,16 +327,19 @@ describe('execute', function () {
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
// Should still succeed — refund went through
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toBe('Refund succeeded, but subscription cancellation failed.');
|
||||
|
||||
$this->subscription->refresh();
|
||||
// Refund timestamp must be recorded
|
||||
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
|
||||
// Subscription should still be marked as ended locally
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBeNull();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBe('sub_test_123');
|
||||
|
||||
$retry = $action->execute($this->team->fresh());
|
||||
|
||||
expect($retry['success'])->toBeFalse();
|
||||
expect($retry['error'])->toContain('already been processed');
|
||||
});
|
||||
|
||||
test('fails when subscription is past refund window', function () {
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Actions\User\DeleteUserAccount;
|
||||
use App\Livewire\Subscription\Actions;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthIdentity;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\RefundService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -98,3 +110,145 @@ describe('password confirmation', function () {
|
||||
->assertReturned('Invalid password.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelImmediately with account deletion', function () {
|
||||
beforeEach(function () {
|
||||
$this->stripe = Mockery::mock(StripeClient::class);
|
||||
$this->stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->instance(StripeClient::class, $this->stripe);
|
||||
});
|
||||
|
||||
test('cancels the subscription, removes the team data from coolify, and deletes the account', function () {
|
||||
$this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123');
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Team GitHub App',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', ['deleteAccount'])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($this->user->id))->toBeNull()
|
||||
->and(Team::find($this->team->id))->toBeNull()
|
||||
->and(Server::withTrashed()->find($server->id))->toBeNull()
|
||||
->and(Project::find($project->id))->toBeNull()
|
||||
->and(Application::withTrashed()->find($application->id))->toBeNull()
|
||||
->and(GithubApp::find($githubApp->id))->toBeNull()
|
||||
->and(Subscription::where('team_id', $this->team->id)->exists())->toBeFalse()
|
||||
->and(auth()->check())->toBeFalse();
|
||||
});
|
||||
|
||||
test('refunds, cancels, and deletes the account when both options are selected', function () {
|
||||
$refund = Mockery::mock(RefundSubscription::class);
|
||||
$refund->shouldReceive('execute')->once()->andReturnUsing(function (Team $team) {
|
||||
$team->subscriptionEnded();
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
});
|
||||
$this->instance(RefundSubscription::class, $refund);
|
||||
$this->stripe->subscriptions->shouldNotReceive('cancel');
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount'])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($this->user->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the account when cancellation fails after a successful refund', function () {
|
||||
$this->stripe->invoices = Mockery::mock(InvoiceService::class);
|
||||
$this->stripe->refunds = Mockery::mock(RefundService::class);
|
||||
$this->stripe->subscriptions->shouldReceive('retrieve')->with('sub_test_123')->andReturn((object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
]);
|
||||
$this->stripe->invoices->shouldReceive('all')->andReturn((object) ['data' => [
|
||||
(object) ['payment_intent' => 'pi_test_123'],
|
||||
]]);
|
||||
$this->stripe->refunds->shouldReceive('create')->once()
|
||||
->with(['payment_intent' => 'pi_test_123'])
|
||||
->andReturn((object) ['id' => 're_test_123']);
|
||||
$this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123')
|
||||
->andThrow(new RuntimeException('Stripe cancel API error'));
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount'])
|
||||
->assertDispatched('error')
|
||||
->assertNotDispatched('success')
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertModelExists($this->user);
|
||||
$this->assertModelExists($this->team);
|
||||
expect($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123')
|
||||
->and($this->team->subscription->fresh()->stripe_refunded_at)->not->toBeNull()
|
||||
->and(auth()->check())->toBeTrue();
|
||||
});
|
||||
|
||||
test('does not cancel when the account cannot be deleted', function () {
|
||||
$member = User::factory()->create();
|
||||
$sharedTeam = Team::factory()->create(['name' => 'Shared']);
|
||||
$sharedTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$sharedTeam->members()->attach($member->id, ['role' => 'member']);
|
||||
$this->stripe->subscriptions->shouldNotReceive('cancel');
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', ['deleteAccount'])
|
||||
->assertReturned('Make another member of the team "Shared" an admin or owner.');
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull()
|
||||
->and($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123');
|
||||
});
|
||||
|
||||
test('keeps the account when delete my account is not selected', function () {
|
||||
$this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123');
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', [])
|
||||
->assertDispatched('success')
|
||||
->assertRedirect(route('subscription.index'));
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull()
|
||||
->and(Team::find($this->team->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('does nothing with a wrong password', function () {
|
||||
$this->stripe->subscriptions->shouldNotReceive('cancel');
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'wrong-password', ['deleteAccount'])
|
||||
->assertReturned('Invalid password.');
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the account and reports an error when deletion fails after the cancellation', function () {
|
||||
$this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123');
|
||||
$deleteAccount = Mockery::mock(DeleteUserAccount::class)->makePartial();
|
||||
$deleteAccount->shouldReceive('handle')->once()->andThrow(new RuntimeException('Runner busy'));
|
||||
$this->instance(DeleteUserAccount::class, $deleteAccount);
|
||||
|
||||
Livewire::test(Actions::class)
|
||||
->call('cancelImmediately', 'password', ['deleteAccount'])
|
||||
->assertDispatched('error')
|
||||
->assertRedirect(route('subscription.index'));
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull()
|
||||
->and(auth()->check())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Paying User',
|
||||
'email' => 'paying@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
Subscription::create([
|
||||
'team_id' => $user->teams()->first()->id,
|
||||
'stripe_subscription_id' => 'sub_browser',
|
||||
'stripe_customer_id' => 'cus_browser',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_browser',
|
||||
]);
|
||||
Team::query()->update(['show_boarding' => false]);
|
||||
Cache::flush();
|
||||
// SQLite returns stripe_invoice_paid as 1, which isSubscriptionActive() treats as unpaid.
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class);
|
||||
});
|
||||
|
||||
it('offers account deletion when cancelling the subscription immediately', function () {
|
||||
$page = visit('/login')
|
||||
->fill('email', 'paying@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertDontSee('These credentials do not match')
|
||||
->navigate('/subscription')
|
||||
->assertSee('Cancel subscription');
|
||||
|
||||
$page->click('button:visible:has-text("Cancel Immediately")')
|
||||
->assertSee('Delete my account.')
|
||||
->screenshot(filename: 'cancel-delete-account-step1')
|
||||
->click('label:has-text("Delete my account.")')
|
||||
->click('button:visible:has-text("Continue")')
|
||||
->assertSee('The following actions will be performed')
|
||||
->screenshot(filename: 'cancel-delete-account-step2');
|
||||
});
|
||||
|
||||
it('cancels, deletes the account, and lands on login without a follow-up request', function () {
|
||||
User::factory()->create(['id' => 0, 'email' => 'root@example.com']);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_browser');
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
$page = visit('/login')
|
||||
->fill('email', 'paying@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertDontSee('These credentials do not match')
|
||||
->navigate('/subscription')
|
||||
->assertSee('Cancel subscription');
|
||||
|
||||
$page->click('button:visible:has-text("Cancel Immediately")')
|
||||
->click('label:has-text("Delete my account.")')
|
||||
->click('button:visible:has-text("Continue")')
|
||||
->fill('[x-model="userConfirmationText"]:visible', "Paying User's Team")
|
||||
->click('button:visible:has-text("Permanently Cancel")')
|
||||
->fill('[x-model="password"]:visible', 'password');
|
||||
|
||||
// Count the Livewire update requests sent after the final confirmation.
|
||||
$page->script("sessionStorage.setItem('livewireUpdates', '0'); const originalFetch = window.fetch; window.fetch = (input, init) => { if (String(input?.url ?? input).includes('/update')) { sessionStorage.setItem('livewireUpdates', String(Number(sessionStorage.getItem('livewireUpdates')) + 1)); } return originalFetch(input, init); };");
|
||||
|
||||
$page->click('button:visible:has-text("Confirm")')
|
||||
->assertPathIs('/login')
|
||||
->wait(1)
|
||||
->screenshot(filename: 'cancel-delete-account-done');
|
||||
|
||||
expect($page->script("sessionStorage.getItem('livewireUpdates')"))->toBe('1')
|
||||
->and(User::query()->where('email', 'paying@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
|
||||
Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
User::factory()->create(['id' => 0, 'name' => 'Root', 'email' => 'root@example.com']);
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'Leaving User',
|
||||
'email' => 'leaving@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
Team::query()->update(['show_boarding' => false]);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('lets a user delete their own account from the profile page', function () {
|
||||
$page = visit('/login')
|
||||
->fill('email', 'leaving@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertDontSee('These credentials do not match')
|
||||
->navigate('/profile')
|
||||
->assertSee('Delete account')
|
||||
->screenshot(filename: 'delete-account-profile');
|
||||
|
||||
$page->click('button:has-text("Delete Account")')
|
||||
->assertSee('Confirm Account Deletion?')
|
||||
->screenshot(filename: 'delete-account-modal')
|
||||
->fill('[x-model="userConfirmationText"]', 'leaving@example.com')
|
||||
->screenshot(filename: 'delete-account-confirm-text')
|
||||
->click('button:visible:has-text("Continue")')
|
||||
->fill('[x-model="password"]', 'password')
|
||||
->screenshot(filename: 'delete-account-password');
|
||||
|
||||
// Count the Livewire update requests sent after the final confirmation.
|
||||
$page->script("sessionStorage.setItem('livewireUpdates', '0'); const originalFetch = window.fetch; window.fetch = (input, init) => { if (String(input?.url ?? input).includes('/update')) { sessionStorage.setItem('livewireUpdates', String(Number(sessionStorage.getItem('livewireUpdates')) + 1)); } return originalFetch(input, init); };");
|
||||
|
||||
$page->click('button:visible:has-text("Permanently Delete")')
|
||||
->assertPathIs('/login')
|
||||
->screenshot(filename: 'delete-account-done');
|
||||
|
||||
expect($page->script("sessionStorage.getItem('livewireUpdates')"))->toBe('1')
|
||||
->and(User::query()->where('email', 'leaving@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user