Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-05 07:45:13 +00:00
16 changed files with 1262 additions and 300 deletions
+1
View File
@@ -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([
+193
View File
@@ -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));
}
}
+27 -1
View File
@@ -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()),
]);
}
}
+93 -48
View File
@@ -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">&middot; {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }}</span>
<span class="text-neutral-500">&middot;</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">&middot;</span>
<x-loading compact text="Loading..." />
@elseif ($nextBillingDate)
<span class="text-neutral-500">&middot;</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">&middot; {{ $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">&middot; {{ $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 &mdash; <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 &mdash; <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);
+311
View File
@@ -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();
});
+54
View File
@@ -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();
});