From 63153aa8b4d44196898ec6ff8adeb6e0933f3674 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:32:16 +0200 Subject: [PATCH] fix(cloud): rework unsubscribed team cleanup and account deletion (#12210) Co-authored-by: Claude Opus 5.5 --- app/Actions/Stripe/CancelSubscription.php | 55 ++- .../Stripe/FindOpenStripeSubscriptions.php | 84 ++++ app/Actions/Stripe/RefundSubscription.php | 2 +- .../Stripe/SyncStripeSubscriptions.php | 3 +- app/Actions/Team/DeleteTeamData.php | 246 +++++++++++ app/Actions/User/DeleteTeamlessUser.php | 32 ++ app/Actions/User/DeleteUserAccount.php | 165 +++++--- .../Cloud/CleanupUnsubscribedTeams.php | 286 ++++++++++--- .../Commands/Cloud/CloudFixSubscription.php | 6 +- app/Jobs/StripeProcessJob.php | 26 +- .../VerifyStripeSubscriptionStatusJob.php | 2 +- app/Livewire/Profile/Index.php | 9 +- app/Livewire/Subscription/Actions.php | 2 +- app/Models/Subscription.php | 21 + app/Models/Team.php | 121 +++--- config/constants.php | 5 + config/horizon.php | 15 +- ...03_add_ended_at_to_subscriptions_table.php | 30 ++ .../CleanupUnsubscribedServersTest.php | 4 +- .../CleanupUnsubscribedTeamsCommandTest.php | 398 +++++++++++++++++- tests/Feature/DeleteTeamDataTest.php | 241 +++++++++++ tests/Feature/ProfileDeleteAccountTest.php | 111 +++++ .../CancelSubscriptionActionsTest.php | 50 ++- .../FindOpenStripeSubscriptionsTest.php | 46 ++ .../Subscription/StripeProcessJobTest.php | 84 ++++ .../Subscription/SubscriptionActionsTest.php | 3 +- .../TeamSubscriptionEndedTest.php | 16 +- tests/Feature/SubscriptionEndedAtTest.php | 86 ++++ tests/Pest.php | 1 + tests/Support/StripeTestHelpers.php | 21 + 30 files changed, 1918 insertions(+), 253 deletions(-) create mode 100644 app/Actions/Stripe/FindOpenStripeSubscriptions.php create mode 100644 app/Actions/Team/DeleteTeamData.php create mode 100644 app/Actions/User/DeleteTeamlessUser.php create mode 100644 database/migrations/2026_10_19_101103_add_ended_at_to_subscriptions_table.php create mode 100644 tests/Feature/DeleteTeamDataTest.php create mode 100644 tests/Feature/Subscription/FindOpenStripeSubscriptionsTest.php create mode 100644 tests/Feature/SubscriptionEndedAtTest.php create mode 100644 tests/Support/StripeTestHelpers.php diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php index 47aa5e2e9c..75f73e5371 100644 --- a/app/Actions/Stripe/CancelSubscription.php +++ b/app/Actions/Stripe/CancelSubscription.php @@ -5,8 +5,10 @@ namespace App\Actions\Stripe; use App\Models\Subscription; use App\Models\User; use Illuminate\Support\Collection; +use Stripe\ErrorObject; use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; +use Stripe\Subscription as StripeSubscription; class CancelSubscription { @@ -166,45 +168,52 @@ class CancelSubscription // Call the team's subscription ended method to handle cleanup if ($subscription->team) { - $subscription->team->subscriptionEnded(); + $subscription->team->subscriptionEnded($subscription); } \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}"); } /** - * Cancel a single subscription by ID (helper method for external use) + * Cancel a subscription immediately and end its local row. A subscription that Stripe already + * ended or no longer has counts as cancelled, so a lapsed owner can still delete the account. */ public static function cancelById(string $subscriptionId): bool { + if (! isCloud()) { + return false; + } + try { - if (! isCloud()) { - return false; - } - $stripe = app(StripeClient::class); - $stripe->subscriptions->cancel($subscriptionId, []); - - // Update local record if exists - $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first(); - if ($subscription) { - $subscription->update([ - 'stripe_cancel_at_period_end' => false, - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_past_due' => false, - ]); - - if ($subscription->team) { - $subscription->team->subscriptionEnded(); - } + if (! self::endedAtStripe($stripe, $subscriptionId)) { + $stripe->subscriptions->cancel($subscriptionId, []); } - - return true; } catch (\Exception $e) { \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage()); return false; } + + Subscription::where('stripe_subscription_id', $subscriptionId) + ->get() + ->each(fn (Subscription $subscription) => $subscription->team?->subscriptionEnded($subscription)); + + return true; + } + + private static function endedAtStripe(StripeClient $stripe, string $subscriptionId): bool + { + try { + $status = $stripe->subscriptions->retrieve($subscriptionId)->status; + } catch (InvalidRequestException $e) { + if ($e->getStripeCode() === ErrorObject::CODE_RESOURCE_MISSING) { + return true; + } + + throw $e; + } + + return in_array($status, [StripeSubscription::STATUS_CANCELED, StripeSubscription::STATUS_INCOMPLETE_EXPIRED], true); } } diff --git a/app/Actions/Stripe/FindOpenStripeSubscriptions.php b/app/Actions/Stripe/FindOpenStripeSubscriptions.php new file mode 100644 index 0000000000..f7121dc569 --- /dev/null +++ b/app/Actions/Stripe/FindOpenStripeSubscriptions.php @@ -0,0 +1,84 @@ + $emails + * @param iterable $customerIds + * @return Collection keyed by subscription id + */ + public function handle(iterable $emails, iterable $customerIds = []): Collection + { + $previousMaxNetworkRetries = Stripe::getMaxNetworkRetries(); + Stripe::setMaxNetworkRetries(2); + + try { + return $this->find(app(StripeClient::class), $emails, collect($customerIds)->filter()->values()); + } finally { + Stripe::setMaxNetworkRetries($previousMaxNetworkRetries); + } + } + + /** + * @param iterable $emails + * @param Collection $customerIds + * @return Collection + */ + private function find(StripeClient $stripe, iterable $emails, Collection $customerIds): Collection + { + // The email filter of Stripe is case-sensitive. + $emails = collect($emails)->filter()->flatMap(fn (string $email): array => [$email, strtolower($email)])->unique(); + foreach ($emails as $email) { + foreach ($stripe->customers->all(['email' => $email, 'limit' => 100])->autoPagingIterator() as $customer) { + $customerIds->push($customer->id); + } + usleep(self::REQUEST_DELAY_MICROSECONDS); + } + + $subscriptions = collect(); + foreach ($customerIds->unique() as $customerId) { + // Without a status filter, Stripe lists every subscription that is not canceled. + foreach ($stripe->subscriptions->all(['customer' => $customerId, 'limit' => 100])->autoPagingIterator() as $subscription) { + if ($subscription->status !== StripeSubscription::STATUS_INCOMPLETE_EXPIRED) { + $subscriptions->put($subscription->id, $subscription); + } + } + usleep(self::REQUEST_DELAY_MICROSECONDS); + } + + return $subscriptions; + } + + /** + * The team that records the subscription, else the team in its metadata. + */ + public static function teamId(StripeSubscription $subscription): ?int + { + $teamId = Subscription::query()->where('stripe_subscription_id', $subscription->id)->value('team_id') + ?? data_get($subscription, 'metadata.team_id'); + + return is_numeric($teamId) ? (int) $teamId : null; + } +} diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index edfa184ad7..2a299eeddf 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -129,7 +129,7 @@ class RefundSubscription 'stripe_past_due' => false, ]); - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); diff --git a/app/Actions/Stripe/SyncStripeSubscriptions.php b/app/Actions/Stripe/SyncStripeSubscriptions.php index 977007961e..1e430bbccd 100644 --- a/app/Actions/Stripe/SyncStripeSubscriptions.php +++ b/app/Actions/Stripe/SyncStripeSubscriptions.php @@ -123,11 +123,12 @@ class SyncStripeSubscriptions } if ($resolution === 'delete_stale') { + $subscription->delete(); + if (! $validReplacement && $inactiveSubscription && $team) { $team->subscriptionEnded($inactiveSubscription); } - $subscription->delete(); $fixedCount++; continue; diff --git a/app/Actions/Team/DeleteTeamData.php b/app/Actions/Team/DeleteTeamData.php new file mode 100644 index 0000000000..1f2238153d --- /dev/null +++ b/app/Actions/Team/DeleteTeamData.php @@ -0,0 +1,246 @@ +purge($team); + + foreach ($team->members()->get() as $member) { + $member->clearStoredTeamIfMatches($team->id); + DB::table('sessions')->where('user_id', $member->id)->delete(); + } + $team->members()->detach(); + foreach (self::NOTIFICATION_SETTINGS_MODELS as $model) { + $model::query()->where('team_id', $team->id)->delete(); + } + + $team->delete(); + + return $counts; + }); + } + + /** + * Deletes the records of the team but keeps the team, its memberships, and notification + * settings reset to the defaults of a new team. + * + * @return array{servers: int, resources: int, projects: int} + */ + public function purge(Team $team): array + { + if ($team->id === 0) { + throw new LogicException('The root team (id 0) is never deleted or purged.'); + } + + return DB::transaction(function () use ($team): array { + $projects = Project::query()->where('team_id', $team->id)->get(); + $environmentIds = Environment::query()->whereIn('project_id', $projects->modelKeys())->pluck('id'); + $servers = Server::withTrashed()->where('team_id', $team->id)->get(); + $resources = $this->resources($environmentIds); + $applicationIds = $resources->whereInstanceOf(Application::class)->modelKeys(); + $serviceIds = $resources->whereInstanceOf(Service::class)->modelKeys(); + $owners = $resources + ->concat(ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->get()) + ->concat(ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->get()) + ->concat(ApplicationPreview::withTrashed()->whereIn('application_id', $applicationIds)->get()); + + $this->forgetGithubRunners($team, $servers->modelKeys()); + + $backupIds = ScheduledDatabaseBackup::query() + ->where('team_id', $team->id) + ->orWhere(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'database')) + ->pluck('id'); + ScheduledDatabaseBackupExecution::query()->whereIn('scheduled_database_backup_id', $backupIds)->delete(); + ScheduledDatabaseBackup::query()->whereKey($backupIds)->delete(); + + $taskIds = ScheduledTask::query() + ->where('team_id', $team->id) + ->orWhereIn('application_id', $applicationIds) + ->orWhereIn('service_id', $serviceIds) + ->pluck('id'); + ScheduledTaskExecution::query()->whereIn('scheduled_task_id', $taskIds)->delete(); + ScheduledTask::query()->whereKey($taskIds)->delete(); + + // Previews are removed with a query: their model hook removes volumes on the server. + ApplicationPreview::withTrashed()->whereIn('application_id', $applicationIds)->forceDelete(); + ApplicationDeploymentQueue::query() + ->whereIn('application_id', array_map('strval', $applicationIds)) + ->orWhereIn('server_id', $servers->modelKeys()) + ->delete(); + + // Without references, deleting a resource does not queue the removal of its DNS records at the team's provider. + ManagedDnsRecordReference::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); + // Volume rows go first, so their backup schedule guards do not block the resource deletion. + LocalPersistentVolume::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); + LocalFileVolume::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); + + foreach ($resources as $resource) { + $resource instanceof Service + ? app(DeleteService::class)->deleteLocal($resource) + : $resource->forceDelete(); + } + ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); + ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); + + EnvironmentVariable::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resourceable'))->delete(); + SecretManagerLink::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resourceable'))->delete(); + SslCertificate::query()->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'resource'))->delete(); + DB::table('taggables')->where(fn (QueryBuilder $query) => $this->whereOwnedBy($query, $owners, 'taggable'))->delete(); + + DockerCleanupExecution::query()->whereIn('server_id', $servers->modelKeys())->delete(); + foreach ($servers as $server) { + $server->forceDelete(); + } + + $iconStorage = app(ProjectIconStorageService::class); + foreach ($projects as $project) { + $iconStorage->deleteProject($project); + $project->delete(); + } + + // Production has cascading foreign keys for most of these; the test schema does not. + $integrationTokenIds = IntegrationToken::query()->where('team_id', $team->id)->pluck('id'); + SecretManagerLink::query()->whereIn('integration_token_id', $integrationTokenIds)->delete(); + ManagedDnsRecord::query()->where('team_id', $team->id)->delete(); + DnsProviderZone::query()->whereIn('integration_token_id', $integrationTokenIds)->delete(); + IntegrationToken::query()->whereKey($integrationTokenIds)->delete(); + $volumeBackupIds = ScheduledVolumeBackup::query()->where('team_id', $team->id)->pluck('id'); + ScheduledVolumeBackupExecution::query()->whereIn('scheduled_volume_backup_id', $volumeBackupIds)->delete(); + ScheduledVolumeBackup::query()->whereKey($volumeBackupIds)->delete(); + foreach ([CloudProviderToken::class, CloudInitScript::class, SharedEnvironmentVariable::class, TeamInvitation::class, Subscription::class, ...self::NOTIFICATION_SETTINGS_MODELS] as $model) { + $model::query()->where('team_id', $team->id)->delete(); + } + $team->deleteOwnedRecords(); + $team->createDefaultNotificationSettings(); + + // The cached team of a member still holds the removed subscription. + foreach ($team->members()->pluck('users.id') as $memberId) { + Cache::forget("user:{$memberId}:team:{$team->id}"); + } + + return [ + 'servers' => $servers->count(), + 'resources' => $resources->count(), + 'projects' => $projects->count(), + ]; + }); + } + + /** + * @param Collection $environmentIds + * @return EloquentCollection + */ + private function resources(Collection $environmentIds): Collection + { + return new EloquentCollection(collect([Application::class, Service::class, ...STANDALONE_DATABASE_MODELS]) + ->flatMap(fn (string $model) => $model::withTrashed()->whereIn('environment_id', $environmentIds)->get()) + ->all()); + } + + /** + * Matches rows of a polymorphic relation that belong to one of the owners. + * + * @param Collection $owners + */ + private function whereOwnedBy(QueryBuilder $query, Collection $owners, string $morph): void + { + $query->whereRaw('1 = 0'); + + foreach ($owners->groupBy(fn (Model $owner) => $owner->getMorphClass()) as $type => $group) { + $query->orWhere(fn (QueryBuilder $query) => $query + ->where("{$morph}_type", $type) + ->whereIn("{$morph}_id", $group->map->getKey()->all())); + } + } + + /** + * Removes the runner records of the team's servers and GitHub Apps, so that busy runners do not + * block deleting them. The runners keep running on the servers; Coolify only stops tracking them. + * + * @param array $serverIds + */ + private function forgetGithubRunners(Team $team, array $serverIds): void + { + $githubAppIds = GithubApp::query()->where('team_id', $team->id)->where('is_system_wide', false)->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(); + } +} diff --git a/app/Actions/User/DeleteTeamlessUser.php b/app/Actions/User/DeleteTeamlessUser.php new file mode 100644 index 0000000000..c4c46c8c19 --- /dev/null +++ b/app/Actions/User/DeleteTeamlessUser.php @@ -0,0 +1,32 @@ +delete($user); + } catch (Throwable $e) { + report($e); + } + + OauthIdentity::query()->where('user_id', $user->id)->delete(); + UserChangelogRead::query()->where('user_id', $user->id)->delete(); + DB::table('password_reset_tokens')->where('email', $user->email)->delete(); + $user->delete(); + DB::table('sessions')->where('user_id', $user->id)->delete(); + } +} diff --git a/app/Actions/User/DeleteUserAccount.php b/app/Actions/User/DeleteUserAccount.php index 24a4febe54..0a21bc761b 100644 --- a/app/Actions/User/DeleteUserAccount.php +++ b/app/Actions/User/DeleteUserAccount.php @@ -3,14 +3,13 @@ namespace App\Actions\User; use App\Actions\Stripe\CancelSubscription; -use App\Models\GithubApp; -use App\Models\GithubRunnerConfig; -use App\Models\GithubRunnerExecution; +use App\Actions\Stripe\FindOpenStripeSubscriptions; +use App\Actions\Team\DeleteTeamData; use App\Models\Subscription; use App\Models\Team; use App\Models\User; -use App\Services\AvatarStorageService; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use RuntimeException; @@ -78,16 +77,12 @@ class DeleteUserAccount 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."); - } - } + foreach ($this->subscriptionsToCancel($user) as $subscriptionId => $team) { + if (! CancelSubscription::cancelById($subscriptionId)) { + $owner = $team ? "the team \"{$team->name}\"" : 'your email'; - try { - app(AvatarStorageService::class)->delete($user); - } catch (\Throwable $e) { - report($e); + throw new RuntimeException("Could not cancel the subscription of {$owner}. Your account was not deleted. Please try again."); + } } DB::transaction(function () use ($user, $removeTeamResources): void { @@ -97,25 +92,18 @@ class DeleteUserAccount throw new RuntimeException(implode(' ', $blockers)); } - if ($removeTeamResources) { - $this->singleMemberTeams($user)->each(fn (Team $team) => $this->removeTeamResources($team)); - } + // Without $removeTeamResources the blockers above guarantee these teams hold no + // projects, servers, or sources. + $this->singleMemberTeams($user)->each(fn (Team $team) => app(DeleteTeamData::class)->handle($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. + // Promotes an admin where the user was the only owner, and removes + // the user from the remaining teams. (new DeleteUserTeams($user))->execute(); - // team_user has no foreign keys, so also remove the root team - // membership and the rows of teams deleted above. + // team_user has no foreign keys, so also remove the root team membership. $user->teams()->detach(); - $user->fresh()->delete(); - - DB::table('sessions')->where('user_id', $user->id)->delete(); + app(DeleteTeamlessUser::class)->handle($user->fresh()); }); } @@ -124,13 +112,29 @@ class DeleteUserAccount */ 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.") + $removedTeams = $this->removesTeamResources($user) + ? $this->singleMemberTeams($user)->reject(fn (Team $team): bool => $team->isEmpty()) + : collect(); + + return $this->subscriptionConfirmationActions($user) + ->concat($removedTeams->map(fn (Team $team): string => "The servers, projects, and Git sources of the team \"{$team->name}\" will be removed from Coolify. Nothing is stopped on the servers.")) ->push('Your account will be permanently deleted from Coolify.') ->values() ->all(); } + /** + * Whether self-service deletion removes the data of the user's single-member teams instead of + * asking the user to delete it first. On Coolify Cloud, a team without a paid or past-due + * subscription cannot open its project and server pages, so the user could not do that. + */ + public function removesTeamResources(User $user): bool + { + return isCloud() && $this->singleMemberTeams($user)->every( + fn (Team $team): bool => ! $team->subscriptions()->get()->contains(fn (Subscription $subscription): bool => $subscription->isPaying()) + ); + } + private function singleMemberTeams(User $user): Collection { return $user->teams() @@ -142,52 +146,77 @@ class DeleteUserAccount } /** - * Removes the team's data from Coolify. Git sources are removed when the - * team itself is deleted. + * The Stripe subscriptions that account deletion cancels, with the team each belongs to. These + * are the subscription rows of the teams the user owns, and every open Stripe subscription of the + * user's email and of those teams' customers, also when Coolify has no row for it. Subscriptions + * of a team that the user does not own are not cancelled. + * + * @return Collection keyed by Stripe subscription id */ - 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 + private function subscriptionsToCancel(User $user): Collection { if (! isCloud()) { return collect(); } - return $user->teams() + $ownedTeams = $user->teams() ->where('teams.id', '!=', 0) ->wherePivot('role', 'owner') - ->with('subscription') - ->get() - ->filter(fn (Team $team): bool => filled($team->subscription?->stripe_subscription_id)); + ->with('subscriptions') + ->get(); + + $subscriptions = collect(); + foreach ($ownedTeams as $team) { + foreach ($team->subscriptions->whereNotNull('stripe_subscription_id') as $subscription) { + $subscriptions->put($subscription->stripe_subscription_id, $team); + } + } + + $customerIds = $ownedTeams->flatMap->subscriptions->pluck('stripe_customer_id'); + foreach (app(FindOpenStripeSubscriptions::class)->handle([$user->email], $customerIds) as $subscriptionId => $stripeSubscription) { + if ($subscriptions->has($subscriptionId)) { + continue; + } + + $teamId = FindOpenStripeSubscriptions::teamId($stripeSubscription); + $team = $teamId === null ? null : Team::find($teamId); + if ($team && ! $ownedTeams->contains($team)) { + continue; + } + + $subscriptions->put($subscriptionId, $team); + } + + return $subscriptions; + } + + /** + * The Profile page renders this on each request, so the Stripe lookup is cached for a short time. + * handle() looks up the subscriptions again. + * + * @return Collection + */ + private function subscriptionConfirmationActions(User $user): Collection + { + if (! isCloud()) { + return collect(); + } + + return collect(Cache::remember("account-deletion-subscriptions:{$user->id}", 60, function () use ($user): array { + try { + return $this->subscriptionsToCancel($user) + ->map(fn (?Team $team, string $subscriptionId): string => $team + ? "The subscription of the team \"{$team->name}\" will be cancelled immediately. This is required." + : "The Stripe subscription {$subscriptionId} of your email, which is not linked to a team, will be cancelled immediately. This is required.") + ->unique() + ->values() + ->all(); + } catch (\Throwable $e) { + // Cached too, so a Stripe outage does not call Stripe and report on every render. + report($e); + + return ['Every subscription of your teams and your email will be cancelled immediately. This is required.']; + } + })); } } diff --git a/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php index 388f82c513..4e84f231d6 100644 --- a/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php +++ b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php @@ -2,22 +2,30 @@ namespace App\Console\Commands\Cloud; +use App\Actions\Stripe\CancelSubscription; +use App\Actions\Stripe\FindOpenStripeSubscriptions; +use App\Actions\Team\DeleteTeamData; +use App\Actions\User\DeleteTeamlessUser; use App\Models\Team; use App\Models\User; use Carbon\CarbonInterface; use Illuminate\Console\Command; +use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use RuntimeException; +use Stripe\Subscription as StripeSubscription; +use Throwable; class CleanupUnsubscribedTeams extends Command { protected $signature = 'cloud:cleanup-unsubscribed-teams - {--months=6 : Months since the subscription ended} + {--days= : Days since the subscription ended or the never-paid team was created (default: constants.cloud.data_retention_days)} {--yes : Delete eligible teams instead of running a dry run}'; - protected $description = 'Delete teams whose subscription ended long ago, with their servers, resources, projects, and users without another team'; + protected $description = 'Delete teams that stopped paying or never paid, with their servers, resources, projects, and users without another team. A personal team of a member of another team keeps only the team and its members'; public function handle(): int { @@ -27,34 +35,37 @@ class CleanupUnsubscribedTeams extends Command return self::FAILURE; } - $months = filter_var($this->option('months'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $days = filter_var($this->option('days') ?? config('constants.cloud.data_retention_days'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - if ($months === false) { - $this->error('The --months option must be a positive integer.'); + if ($days === false) { + $this->error('The --days option must be a positive integer.'); return self::FAILURE; } - $cutoff = now()->subMonths($months); + $cutoff = now()->subDays($days); $teams = $this->eligibleTeams($cutoff) - ->with('subscription') + ->withMax('subscriptions', 'ended_at') ->withCount(['servers', 'projects', 'members']) + ->orderBy('id') ->get(); - $orphanedUserIds = $this->orphanedUsers($teams->modelKeys())->pluck('id'); - $this->info("Found {$teams->count()} ".Str::plural('team', $teams->count())." with a subscription that ended before {$cutoff->toDateString()}."); - $this->info("Found {$orphanedUserIds->count()} ".Str::plural('user', $orphanedUserIds->count()).' without another team.'); + $stripeChecks = $teams->mapWithKeys(fn (Team $team): array => [$team->id => $this->stripeCheck($team)]); + + $this->info("Found {$teams->count()} ".Str::plural('team', $teams->count())." that stopped paying or never paid before {$cutoff->toDateString()}."); if ($teams->isNotEmpty()) { $this->table( - ['Team', 'Name', 'Servers', 'Projects', 'Members', 'Subscription updated'], + ['Team', 'Name', 'Action', 'Servers', 'Projects', 'Members', 'Subscription ended', 'Created'], $teams->map(fn (Team $team) => [ $team->id, $team->name, + $this->action($team, $stripeChecks[$team->id]), $team->servers_count, $team->projects_count, $team->members_count, - $team->subscription->updated_at?->toDateString(), + $team->subscriptions_max_ended_at ? Str::before($team->subscriptions_max_ended_at, ' ') : 'never paid', + $team->created_at?->toDateString(), ]), ); } @@ -65,71 +76,218 @@ class CleanupUnsubscribedTeams extends Command return self::SUCCESS; } - foreach ($teams as $team) { - DB::transaction(fn () => $this->deleteTeam($team)); - } - + $deletedTeamCount = 0; + $keptTeamCount = 0; $deletedUserCount = 0; + /** @var array $skippedTeams team id => reason */ + $skippedTeams = []; + /** @var array $failedTeams team id => error */ + $failedTeams = []; - foreach (User::query()->whereKey($orphanedUserIds)->whereDoesntHave('teams')->lazyById(100) as $user) { - if ($user->delete()) { - $deletedUserCount++; + foreach ($teams as $team) { + ['hold' => $hold, 'cancel' => $subscriptionIds] = $stripeChecks[$team->id]; + if ($hold) { + $skippedTeams[$team->id] = $hold; + Log::warning('Skipped an unsubscribed team.', ['team_id' => $team->id, 'reason' => $hold]); + + continue; } + + try { + // Cancel first, so a failed cancel keeps the team for the next run. + foreach ($subscriptionIds as $subscriptionId) { + if (! CancelSubscription::cancelById($subscriptionId)) { + throw new RuntimeException("Could not cancel the Stripe subscription {$subscriptionId}."); + } + } + + $result = $this->deleteTeam($team, $cutoff); + } catch (Throwable $e) { + $failedTeams[$team->id] = $e->getMessage(); + Log::error('Could not delete an unsubscribed team.', ['team_id' => $team->id, 'error' => $e->getMessage()]); + + continue; + } + + if ($result === null) { + continue; + } + + if ($result['kept_team']) { + $keptTeamCount++; + } else { + $deletedTeamCount++; + } + $deletedUserCount += $result['users']; + Log::info($result['kept_team'] ? 'Removed the data of an unsubscribed personal team and kept the team.' : 'Deleted an unsubscribed team.', [ + 'team_id' => $team->id, + 'subscription_ended_at' => $team->subscriptions_max_ended_at, + 'team_created_at' => $team->created_at?->toDateTimeString(), + 'cancelled_stripe_subscriptions' => $subscriptionIds, + ...$result, + ]); } - $this->info("Deleted {$teams->count()} ".Str::plural('team', $teams->count())." and {$deletedUserCount} ".Str::plural('user', $deletedUserCount).'.'); + $this->info("Deleted {$deletedTeamCount} ".Str::plural('team', $deletedTeamCount)." and {$deletedUserCount} ".Str::plural('user', $deletedUserCount).'.'); + if ($keptTeamCount > 0) { + $this->info("Removed the data of {$keptTeamCount} personal ".Str::plural('team', $keptTeamCount).' and kept '.($keptTeamCount === 1 ? 'the team' : 'the teams').'.'); + } + + if ($skippedTeams) { + $this->warn('Skipped '.count($skippedTeams).' '.Str::plural('team', count($skippedTeams)).' because of Stripe. See the log for details.'); + } + + if ($failedTeams) { + $this->error('Could not delete '.count($failedTeams).' '.Str::plural('team', count($failedTeams)).'. See the log for details.'); + } + + if ($skippedTeams || $failedTeams) { + send_internal_notification($this->summary($skippedTeams, $failedTeams)); + } + + return $failedTeams ? self::FAILURE : self::SUCCESS; return self::SUCCESS; } /** - * Teams whose Stripe subscription ended (no subscription id, unpaid) and did not change since the cutoff. + * Deletes the team and its members that have no other team, as one unit, so a failure leaves + * both in place for the next run. A team that keepsTeam() loses only its data. Returns null + * when the team is no longer eligible. + * + * @return array{servers: int, resources: int, projects: int, users: int, kept_team: bool}|null + */ + private function deleteTeam(Team $team, CarbonInterface $cutoff): ?array + { + return DB::transaction(function () use ($team, $cutoff): ?array { + $team = $this->eligibleTeams($cutoff)->whereKey($team->id)->lockForUpdate()->first(); + if (! $team) { + return null; + } + + if ($this->keepsTeam($team)) { + return [...app(DeleteTeamData::class)->purge($team), 'users' => 0, 'kept_team' => true]; + } + + $memberIds = $team->members()->pluck('users.id'); + $counts = app(DeleteTeamData::class)->handle($team); + + $teamlessMembers = User::query()->whereKey($memberIds)->whereKeyNot(0)->whereDoesntHave('teams')->get(); + foreach ($teamlessMembers as $user) { + app(DeleteTeamlessUser::class)->handle($user); + } + + return [...$counts, 'users' => $teamlessMembers->count(), 'kept_team' => false]; + }); + } + + /** + * Checks the Stripe subscriptions of the team's customers and of every customer with the email of + * an owner or admin. A subscription counts for the team unless it belongs to another team that + * exists. A paying one, or a failed check, keeps the team ('hold'). The others that did not end, + * for example unpaid or paused ones, are cancelled before the team is deleted ('cancel'). + * + * @return array{hold: ?string, cancel: list} + */ + private function stripeCheck(Team $team): array + { + try { + $subscriptions = app(FindOpenStripeSubscriptions::class) + ->handle( + $team->members()->wherePivotIn('role', ['owner', 'admin'])->pluck('email'), + $team->subscriptions()->pluck('stripe_customer_id'), + ) + ->filter(function (StripeSubscription $subscription) use ($team): bool { + $teamId = FindOpenStripeSubscriptions::teamId($subscription); + + return $teamId === null || $teamId === $team->id || Team::query()->whereKey($teamId)->doesntExist(); + }); + } catch (Throwable $e) { + return ['hold' => "keep: Stripe check failed ({$e->getMessage()})", 'cancel' => []]; + } + + $paying = $subscriptions->first(fn (StripeSubscription $subscription): bool => in_array($subscription->status, FindOpenStripeSubscriptions::PAYING_STATUSES, true)); + + return [ + 'hold' => $paying ? "keep: Stripe subscription {$paying->id} is {$paying->status}" : null, + 'cancel' => $paying ? [] : $subscriptions->keys()->all(), + ]; + } + + /** + * @param array{hold: ?string, cancel: list} $stripeCheck + */ + private function action(Team $team, array $stripeCheck): string + { + if ($stripeCheck['hold']) { + return $stripeCheck['hold']; + } + + $action = $this->keepsTeam($team) ? 'remove data, keep team' : 'delete team'; + + return $stripeCheck['cancel'] ? "{$action}, cancel ".implode(', ', $stripeCheck['cancel']) : $action; + } + + /** + * One notification per run, so a large run or a Stripe outage does not send one per team. + * + * @param array $skippedTeams + * @param array $failedTeams + */ + private function summary(array $skippedTeams, array $failedTeams): string + { + $lines = ['cloud:cleanup-unsubscribed-teams:']; + foreach (['Skipped' => $skippedTeams, 'Could not delete' => $failedTeams] as $label => $teams) { + if ($teams) { + $lines[] = "{$label} ".count($teams).' '.Str::plural('team', count($teams)).':'; + foreach (array_slice($teams, 0, 20, true) as $teamId => $reason) { + $lines[] = "- team {$teamId}: {$reason}"; + } + if (count($teams) > 20) { + $lines[] = '- and '.(count($teams) - 20).' more. See the log.'; + } + } + } + + return implode("\n", $lines); + } + + /** + * Whether the team is the personal team of a user who also belongs to another team. Such a team + * keeps its row and members, so the user keeps their personal team. + */ + private function keepsTeam(Team $team): bool + { + return $team->personal_team && DB::table('team_user') + ->whereIn('user_id', $team->members()->select('users.id')) + ->where('team_id', '!=', $team->id) + ->exists(); + } + + /** + * Teams without a paying (paid or past due) subscription row, where either every row ended + * before the cutoff, or the team never had a Stripe customer, was created before the cutoff, + * and none of its members belongs to another team. */ private function eligibleTeams(CarbonInterface $cutoff): Builder { return Team::query() ->whereKeyNot(0) - ->whereHas('subscription', fn (Builder $query) => $query - ->where('stripe_invoice_paid', false) - ->whereNull('stripe_subscription_id') - ->whereNotNull('stripe_customer_id') - ->where('updated_at', '<', $cutoff)); - } - - /** - * Users that belong only to the given teams. - * - * @param array $teamIds - */ - private function orphanedUsers(array $teamIds): Builder - { - return User::query() - ->whereKeyNot(0) - ->whereHas('teams', fn (Builder $query) => $query->whereIn('teams.id', $teamIds)) - ->whereDoesntHave('teams', fn (Builder $query) => $query->whereNotIn('teams.id', $teamIds)); - } - - private function deleteTeam(Team $team): void - { - foreach ($team->servers()->withTrashed()->get() as $server) { - foreach ($server->definedResources() as $resource) { - $resource->forceDelete(); - } - $server->forceDelete(); - } - - foreach ($team->projects()->get() as $project) { - $project->delete(); - } - - foreach ($team->members()->get() as $member) { - $member->clearStoredTeamIfMatches($team->id); - DB::table('sessions')->where('user_id', $member->id)->delete(); - Cache::forget("user:{$member->id}:team:{$team->id}"); - } - $team->members()->detach(); - - $team->subscription()->delete(); - $team->delete(); + ->whereDoesntHave('subscriptions', fn (Builder $query) => $query + ->where(fn (Builder $query) => $query->where('stripe_invoice_paid', true)->orWhere('stripe_past_due', true))) + ->where(fn (Builder $query) => $query + ->where(fn (Builder $query) => $query + ->whereHas('subscriptions', fn (Builder $query) => $query->whereNotNull('stripe_customer_id')) + ->whereDoesntHave('subscriptions', fn (Builder $query) => $query + ->where(fn (Builder $query) => $query->whereNull('ended_at')->orWhere('ended_at', '>=', $cutoff)))) + ->orWhere(fn (Builder $query) => $query + ->whereDoesntHave('subscriptions', fn (Builder $query) => $query->whereNotNull('stripe_customer_id')) + ->where('created_at', '<', $cutoff) + ->whereNotExists(fn (QueryBuilder $query) => $query + ->selectRaw('1') + ->from('team_user as membership') + ->join('team_user as other_membership', 'other_membership.user_id', '=', 'membership.user_id') + ->whereColumn('membership.team_id', 'teams.id') + ->whereColumn('other_membership.team_id', '!=', 'teams.id')))); } } diff --git a/app/Console/Commands/Cloud/CloudFixSubscription.php b/app/Console/Commands/Cloud/CloudFixSubscription.php index 55012c0062..65a4fa4986 100644 --- a/app/Console/Commands/Cloud/CloudFixSubscription.php +++ b/app/Console/Commands/Cloud/CloudFixSubscription.php @@ -208,7 +208,7 @@ class CloudFixSubscription extends Command send_internal_notification($notificationMessage); // Apply the same logic as customer.subscription.deleted webhook - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); $fixedCount++; $this->info(" ✓ Fixed subscription for Team ID: {$team->id}"); @@ -280,7 +280,7 @@ class CloudFixSubscription extends Command send_internal_notification($notificationMessage); // Apply the same logic as customer.subscription.deleted webhook - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); $fixedCount++; $this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}"); @@ -726,7 +726,7 @@ class CloudFixSubscription extends Command send_internal_notification($message); // Call the team's subscription ended method which properly cleans up - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); } /** diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 58e67de085..9e4e9f300b 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Actions\Stripe\CancelSubscription; +use App\Actions\Stripe\FindOpenStripeSubscriptions; use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Subscription; use App\Models\Team; @@ -45,14 +47,18 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue $pi = data_get($data, 'payment_intent'); $piData = $stripe->paymentIntents->retrieve($pi, []); $customerId = data_get($piData, 'customer'); - $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); - if ($subscription) { - $subscriptionId = data_get($subscription, 'stripe_subscription_id'); - $stripe->subscriptions->cancel($subscriptionId, []); - $subscription->update([ - 'stripe_invoice_paid' => false, - ]); - send_internal_notification("Early fraud warning created Refunded, subscription canceled. Charge: {$charge}, id: {$id}, pi: {$pi}"); + // Cancel every subscription of the email, also those of its other Stripe customers. + $email = $customerId ? $stripe->customers->retrieve($customerId)->email : null; + $subscriptionIds = app(FindOpenStripeSubscriptions::class)->handle([$email], [$customerId])->keys() + ->merge(Subscription::where('stripe_customer_id', $customerId)->whereNotNull('stripe_subscription_id')->pluck('stripe_subscription_id')) + ->unique(); + if ($subscriptionIds->isNotEmpty()) { + $failedIds = $subscriptionIds->reject(fn (string $subscriptionId): bool => CancelSubscription::cancelById($subscriptionId)); + if ($failedIds->isNotEmpty()) { + send_internal_notification("Early fraud warning: refunded, but could not cancel subscriptions {$failedIds->implode(', ')}. Charge: {$charge}, id: {$id}, pi: {$pi}"); + throw new \RuntimeException("Early fraud warning: could not cancel subscriptions {$failedIds->implode(', ')}."); + } + send_internal_notification("Early fraud warning created Refunded, subscriptions canceled: {$subscriptionIds->implode(', ')}. Email: {$email}, Charge: {$charge}, id: {$id}, pi: {$pi}"); } else { send_internal_notification("Early fraud warning: subscription not found. Charge: {$charge}, id: {$id}, pi: {$pi}"); throw new \RuntimeException("Early fraud warning: subscription not found. Charge: {$charge}, id: {$id}, pi: {$pi}"); @@ -313,7 +319,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue } $team = data_get($subscription, 'team'); if ($team) { - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); } else { // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); @@ -342,7 +348,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue if ($subscription) { $team = data_get($subscription, 'team'); if ($team) { - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); } else { // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index 35b41a369b..bf5e7ad3d9 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -85,7 +85,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu $team = $this->subscription->team; if ($team) { - $team->subscriptionEnded(); + $team->subscriptionEnded($this->subscription); } break; diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 95f1f66a06..e782de176a 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -355,7 +355,8 @@ class Index extends Component return 'The provided password is incorrect.'; } - app(DeleteUserAccount::class)->handle(Auth::user()); + $deleteUserAccount = app(DeleteUserAccount::class); + $deleteUserAccount->handle(Auth::user(), removeTeamResources: $deleteUserAccount->removesTeamResources(Auth::user())); auditLog('ui.user.account_deleted', ['resource' => 'user']); Auth::guard('web')->logout(); @@ -391,9 +392,11 @@ class Index extends Component public function render() { + $deleteUserAccount = app(DeleteUserAccount::class); + return view('livewire.profile.index', [ - 'accountDeletionBlockers' => app(DeleteUserAccount::class)->blockers(Auth::user()), - 'accountDeletionActions' => app(DeleteUserAccount::class)->confirmationActions(Auth::user()), + 'accountDeletionBlockers' => $deleteUserAccount->blockers(Auth::user(), $deleteUserAccount->removesTeamResources(Auth::user())), + 'accountDeletionActions' => $deleteUserAccount->confirmationActions(Auth::user()), ]); } } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index dcb99a1115..8129aff14d 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -247,7 +247,7 @@ class Actions extends Component 'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(), ]); - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); $this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]); diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 3b3432224b..35df71858e 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -27,9 +27,30 @@ class Subscription extends Model { return [ 'stripe_refunded_at' => 'datetime', + 'ended_at' => 'datetime', ]; } + /** + * ended_at marks when the team stopped paying, which starts the data retention period. + * A past-due subscription is still in dunning, so it counts as paying. + */ + protected static function booted(): void + { + static::saving(function (Subscription $subscription): void { + if ($subscription->isPaying()) { + $subscription->ended_at = null; + } elseif ($subscription->ended_at === null) { + $subscription->ended_at = now(); + } + }); + } + + public function isPaying(): bool + { + return (bool) $this->stripe_invoice_paid || (bool) $this->stripe_past_due; + } + public function team() { return $this->belongsTo(Team::class); diff --git a/app/Models/Team.php b/app/Models/Team.php index 349c5b5681..864c47be2f 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -16,6 +16,7 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -73,15 +74,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen protected static function booted() { - static::created(function ($team) { - $team->emailNotificationSettings()->create([ - 'use_instance_email_settings' => isDev(), - ]); - $team->discordNotificationSettings()->create(); - $team->slackNotificationSettings()->create(); - $team->telegramNotificationSettings()->create(); - $team->pushoverNotificationSettings()->create(); - $team->webhookNotificationSettings()->create(); + static::created(function (Team $team) { + $team->createDefaultNotificationSettings(); }); static::updating(function ($team) { @@ -91,40 +85,65 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen }); static::deleting(function (Team $team) { - RevokeUserTeamTokens::forTeam($team->id); - - foreach ($team->privateKeys as $key) { - $key->delete(); - } - - // Transfer instance-wide sources to root team so they remain available - $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() - ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); - foreach ($systemWideSources as $source) { - $source->update(['team_id' => 0]); - } - - // Delete non-instance-wide sources owned by this team - $teamSources = GithubApp::where('team_id', $team->id)->get() - ->concat(GitlabApp::where('team_id', $team->id)->get()); - foreach ($teamSources as $source) { - $source->delete(); - } - - foreach (Tag::whereTeamId($team->id)->get() as $tag) { - $tag->delete(); - } - - foreach ($team->environment_variables()->get() as $sharedVariable) { - $sharedVariable->delete(); - } - - foreach ($team->s3s as $s3) { - $s3->delete(); - } + $team->deleteOwnedRecords(); }); } + /** + * Creates the notification settings rows every team needs, with their default values. + */ + public function createDefaultNotificationSettings(): void + { + $this->emailNotificationSettings()->create([ + 'use_instance_email_settings' => isDev(), + ]); + $this->discordNotificationSettings()->create(); + $this->slackNotificationSettings()->create(); + $this->telegramNotificationSettings()->create(); + $this->pushoverNotificationSettings()->create(); + $this->webhookNotificationSettings()->create(); + } + + /** + * Deletes the API tokens, private keys, sources, tags, shared variables, and S3 storages of the + * team. Instance-wide sources move to the root team instead. + */ + public function deleteOwnedRecords(): void + { + RevokeUserTeamTokens::forTeam($this->id); + + foreach ($this->privateKeys()->get() as $key) { + $key->delete(); + } + + // Transfer instance-wide sources to root team so they remain available + $systemWideSources = GithubApp::where('team_id', $this->id)->where('is_system_wide', true)->get() + ->concat(GitlabApp::where('team_id', $this->id)->where('is_system_wide', true)->get()); + foreach ($systemWideSources as $source) { + $source->update(['team_id' => 0]); + } + + // Delete non-instance-wide sources owned by this team + $teamSources = GithubApp::where('team_id', $this->id)->get() + ->concat(GitlabApp::where('team_id', $this->id)->get()); + foreach ($teamSources as $source) { + $source->delete(); + } + + foreach (Tag::whereTeamId($this->id)->get() as $tag) { + $tag->delete(); + } + + foreach ($this->environment_variables()->get() as $sharedVariable) { + $sharedVariable->delete(); + } + + // Also unusable storages, which the s3s relation hides. + foreach (S3Storage::query()->where('team_id', $this->id)->get() as $s3) { + $s3->delete(); + } + } + public static function serverLimitReached(?Team $team = null) { $team = $team ?? currentTeam(); @@ -256,14 +275,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen $this->getNotificationSettings('webhook')?->isEnabled(); } - public function subscriptionEnded(?Subscription $subscription = null): void + /** + * Ends one subscription row. The servers stay usable while another row of the team still pays. + */ + public function subscriptionEnded(Subscription $subscription): void { - $subscription ??= $this->subscription; - - if (! $subscription) { - return; - } - $subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, @@ -271,6 +287,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen 'stripe_trial_already_ended' => false, 'stripe_past_due' => false, ]); + if ($this->subscriptions()->get()->contains(fn (Subscription $other): bool => $other->isPaying())) { + return; + } foreach ($this->servers as $server) { $server->settings()->update([ 'is_usable' => false, @@ -319,6 +338,14 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasOne(Subscription::class); } + /** + * A team can have more than one subscription row, for example after it subscribed again. + */ + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + public function applications() { return $this->hasManyThrough(Application::class, Project::class); diff --git a/config/constants.php b/config/constants.php index e4ee6aedb5..4a652fb3b9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -27,6 +27,11 @@ return [ 'scheduled_jobs_dispatch_mode' => env('SCHEDULED_JOBS_DISPATCH_MODE'), ], + 'cloud' => [ + // Days after a subscription ends (or after a never-paid team was created) before cloud:cleanup-unsubscribed-teams deletes the team's data. + 'data_retention_days' => (int) env('CLOUD_DATA_RETENTION_DAYS', 30), + ], + 'urls' => [ 'docs' => 'https://coolify.io/docs', 'contact' => 'https://coolify.io/docs/contact', diff --git a/config/horizon.php b/config/horizon.php index 6c1a781173..6b780013dc 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -47,16 +47,18 @@ $selfHostedSupervisor = $workerOptions + [ | webhooks, Stripe events, and server limit checks, so other jobs cannot | delay them. */ -$cloudSupervisors = []; - -foreach ([ +$cloudQueues = [ 'deployments' => ['HORIZON_DEPLOYMENTS_PROCESSES', 60], 'crons' => ['HORIZON_CRONS_PROCESSES', 60], 'high' => ['HORIZON_HIGH_PROCESSES', 60], 'default' => ['HORIZON_DEFAULT_PROCESSES', 40], 'maintenance' => ['HORIZON_MAINTENANCE_PROCESSES', 60], 'webhooks' => ['HORIZON_WEBHOOKS_PROCESSES', 10], -] as $queue => [$processesEnv, $defaultProcesses]) { +]; + +$cloudSupervisors = []; + +foreach ($cloudQueues as $queue => [$processesEnv, $defaultProcesses]) { $processes = filter_var(env($processesEnv), FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 1, 'default' => $defaultProcesses], ]); @@ -271,6 +273,9 @@ return [ 'environments' => [ 'production' => $isCloud ? $cloudSupervisors : ['s6' => $selfHostedSupervisor], - 'local' => ['s6' => $selfHostedSupervisor], + // Local Cloud mode runs every Cloud queue in the one dev supervisor. + 'local' => ['s6' => $isCloud + ? ['queue' => env('HORIZON_QUEUES', 'high,default,'.implode(',', array_diff(array_keys($cloudQueues), ['high', 'default'])))] + $selfHostedSupervisor + : $selfHostedSupervisor], ], ]; diff --git a/database/migrations/2026_10_19_101103_add_ended_at_to_subscriptions_table.php b/database/migrations/2026_10_19_101103_add_ended_at_to_subscriptions_table.php new file mode 100644 index 0000000000..761a2b3710 --- /dev/null +++ b/database/migrations/2026_10_19_101103_add_ended_at_to_subscriptions_table.php @@ -0,0 +1,30 @@ +timestamp('ended_at')->nullable()->index()->after('stripe_refunded_at'); + }); + + // updated_at is the best available estimate for rows that ended before this column existed. + DB::table('subscriptions') + ->where('stripe_invoice_paid', false) + ->where('stripe_past_due', false) + ->update(['ended_at' => DB::raw('updated_at')]); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropIndex(['ended_at']); + $table->dropColumn('ended_at'); + }); + } +}; diff --git a/tests/Feature/CleanupUnsubscribedServersTest.php b/tests/Feature/CleanupUnsubscribedServersTest.php index edf6c63a0b..180857535a 100644 --- a/tests/Feature/CleanupUnsubscribedServersTest.php +++ b/tests/Feature/CleanupUnsubscribedServersTest.php @@ -11,7 +11,7 @@ uses(RefreshDatabase::class); it('sets unreachable fields on servers when subscription ends', function () { $team = Team::factory()->create(); - Subscription::create([ + $subscription = Subscription::create([ 'team_id' => $team->id, 'stripe_invoice_paid' => true, ]); @@ -20,7 +20,7 @@ it('sets unreachable fields on servers when subscription ends', function () { 'unreachable_count' => 0, ]); - $team->subscriptionEnded(); + $team->subscriptionEnded($subscription); $server->refresh(); expect($server->unreachable_count)->toBe(3); diff --git a/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php index 67a3351a72..c62be5f3e8 100644 --- a/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php +++ b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php @@ -1,23 +1,36 @@ set('constants.coolify.self_hosted', false); + Process::fake(); + fakeOpenStripeSubscriptions(); }); function endedSubscription(Team $team, array $attributes = []): Subscription { return Subscription::factory()->create([ 'team_id' => $team->id, - 'updated_at' => now()->subMonths(7), + 'ended_at' => now()->subDays(31), ...$attributes, ]); } @@ -34,12 +47,12 @@ test('it refuses to run outside Coolify Cloud', function () { $this->assertModelExists($team); }); -test('it rejects a months value that is not a positive integer', function (string $months) { +test('it rejects a days value that is not a positive integer', function (string $days) { $team = Team::factory()->create(); endedSubscription($team); - $this->artisan('cloud:cleanup-unsubscribed-teams', ['--months' => $months, '--yes' => true]) - ->expectsOutput('The --months option must be a positive integer.') + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--days' => $days, '--yes' => true]) + ->expectsOutput('The --days option must be a positive integer.') ->assertFailed(); $this->assertModelExists($team); @@ -58,8 +71,10 @@ test('it previews eligible teams without deleting them', function () { $this->assertModelExists($team); }); -test('it deletes an eligible team with its subscription, servers, projects, and orphaned owner', function () { - $owner = User::factory()->create(); +test('it deletes an eligible team with its subscription, servers, projects, and orphaned owner without running commands on the servers', function () { + Storage::fake('images'); + $owner = User::factory()->create(['avatar_path' => 'avatars/owner.jpg', 'avatar_storage_type' => 'local']); + Storage::disk('images')->put('avatars/owner.jpg', 'jpg'); $team = $owner->teams()->first(); $subscription = endedSubscription($team); $server = Server::factory()->create(['team_id' => $team->id]); @@ -75,7 +90,9 @@ test('it deletes an eligible team with its subscription, servers, projects, and $this->assertModelMissing($subscription); $this->assertModelMissing($project); $this->assertModelMissing($owner); - expect(Server::withTrashed()->whereKey([$server->id, $trashedServer->id])->exists())->toBeFalse(); + expect(Server::withTrashed()->whereKey([$server->id, $trashedServer->id])->exists())->toBeFalse() + ->and(Storage::disk('images')->exists('avatars/owner.jpg'))->toBeFalse(); + Process::assertNothingRan(); }); test('it keeps members with another team and detaches them from the deleted team', function () { @@ -94,9 +111,11 @@ test('it keeps members with another team and detaches them from the deleted team $this->assertDatabaseMissing('team_user', ['team_id' => $team->id]); }); -test('it keeps teams that are not eligible', function (array $attributes) { - $team = Team::factory()->create(); - endedSubscription($team, $attributes); +test('it keeps teams that are not eligible', function (array $subscriptionRows) { + $team = Team::factory()->create(['created_at' => now()->subYear()]); + foreach ($subscriptionRows as $attributes) { + endedSubscription($team, $attributes); + } $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) ->expectsOutput('Deleted 0 teams and 0 users.') @@ -104,19 +123,364 @@ test('it keeps teams that are not eligible', function (array $attributes) { $this->assertModelExists($team); })->with([ - 'paid subscription' => [['stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_paid']], - 'paused subscription that still exists in Stripe' => [['stripe_subscription_id' => 'sub_paused']], - 'subscription ended within the cutoff' => [['updated_at' => now()->subMonths(3)]], - 'subscription without a Stripe customer' => [['stripe_customer_id' => null]], + 'paid subscription' => [[['stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_paid']]], + 'past due subscription' => [[['stripe_past_due' => true, 'stripe_subscription_id' => 'sub_due']]], + 'subscription ended within the grace period' => [[['ended_at' => now()->subDays(29)]]], + 'an old ended row and a current paying row' => [[['ended_at' => now()->subYear()], ['stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_new']]], + 'an old ended row and a recently ended row' => [[['ended_at' => now()->subYear()], ['ended_at' => now()->subDays(5)]]], ]); -test('it respects the months option', function () { +test('it deletes teams that stopped paying but keep a Stripe subscription id', function (array $attributes) { $team = Team::factory()->create(); - endedSubscription($team, ['updated_at' => now()->subMonths(4)]); + endedSubscription($team, ['stripe_subscription_id' => 'sub_kept', ...$attributes]); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 0 users.') + ->assertSuccessful(); + + $this->assertModelMissing($team); +})->with([ + 'refunded after a fraud warning' => [['stripe_refunded_at' => now()->subDays(40)]], + 'paused or incomplete_expired' => [['stripe_plan_id' => 'price_paused']], +]); + +test('it starts the grace period when the subscription ended, not when the row was last updated', function () { + $team = Team::factory()->create(); + $subscription = endedSubscription($team, ['ended_at' => now()->subDays(31)]); + $subscription->update(['stripe_feedback' => 'too_expensive']); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->assertModelMissing($team); +}); + +test('it respects the days option', function () { + $team = Team::factory()->create(); + endedSubscription($team, ['ended_at' => now()->subDays(10)]); $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); $this->assertModelExists($team); - $this->artisan('cloud:cleanup-unsubscribed-teams', ['--months' => 3, '--yes' => true])->assertSuccessful(); + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--days' => 7, '--yes' => true])->assertSuccessful(); $this->assertModelMissing($team); }); + +test('it uses the configured retention period by default', function () { + config()->set('constants.cloud.data_retention_days', 5); + $team = Team::factory()->create(); + endedSubscription($team, ['ended_at' => now()->subDays(6)]); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->assertModelMissing($team); +}); + +test('it deletes a never-paid team after the grace period together with its member', function (array $subscription) { + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + $team->forceFill(['created_at' => now()->subDays(31)])->save(); + if ($subscription !== []) { + Subscription::factory()->create(['team_id' => $team->id, ...$subscription]); + } + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 1 user.') + ->assertSuccessful(); + + $this->assertModelMissing($team); + $this->assertModelMissing($owner); +})->with([ + 'without a subscription row' => [[]], + 'with a row without a Stripe customer' => [['stripe_customer_id' => null]], +]); + +test('it keeps a never-paid team that is younger than the grace period', function () { + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + $team->forceFill(['created_at' => now()->subDays(29)])->save(); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->assertModelExists($team); + $this->assertModelExists($owner); +}); + +test('it keeps the never-paid personal team of a user who is a member of another team', function () { + $user = User::factory()->create(); + $personalTeam = $user->teams()->first(); + $personalTeam->forceFill(['created_at' => now()->subYear()])->save(); + $payingTeam = Team::factory()->create(['created_at' => now()->subYear()]); + Subscription::factory()->create(['team_id' => $payingTeam->id, 'stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_paying']); + $payingTeam->members()->attach($user, ['role' => 'member']); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 0 teams and 0 users.') + ->assertSuccessful(); + + $this->assertModelExists($personalTeam); + $this->assertModelExists($user); +}); + +/** + * A user whose personal team stopped paying, who is also a member of a paying team. + * + * @return array{user: User, personalTeam: Team, payingTeam: Team} + */ +function lapsedPersonalTeamOfPayingTeamMember(): array +{ + $user = User::factory()->create(); + $personalTeam = $user->teams()->first(); + $payingTeam = Team::factory()->create(['name' => 'Paying Team']); + Subscription::factory()->create(['team_id' => $payingTeam->id, 'stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_paying']); + $payingTeam->members()->attach($user, ['role' => 'member']); + $user->update(['current_team_id' => $personalTeam->id]); + + return ['user' => $user, 'personalTeam' => $personalTeam, 'payingTeam' => $payingTeam]; +} + +test('it keeps the lapsed personal team of a member of another team and removes only its data', function () { + ['user' => $user, 'personalTeam' => $personalTeam, 'payingTeam' => $payingTeam] = lapsedPersonalTeamOfPayingTeamMember(); + $subscription = endedSubscription($personalTeam); + $privateKey = PrivateKey::factory()->create(['team_id' => $personalTeam->id]); + $server = Server::factory()->create(['team_id' => $personalTeam->id, 'private_key_id' => $privateKey->id]); + $project = Project::factory()->create(['team_id' => $personalTeam->id]); + $personalTeam->emailNotificationSettings()->update(['smtp_enabled' => true, 'smtp_from_address' => 'owner@example.com']); + $personalTeam->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => encrypt('https://example.com/webhook')]); + $payingServer = Server::factory()->create(['team_id' => $payingTeam->id]); + $sessionId = Str::random(40); + DB::table('sessions')->insert(['id' => $sessionId, 'user_id' => $user->id, 'payload' => '', 'last_activity' => now()->timestamp]); + + $this->artisan('cloud:cleanup-unsubscribed-teams') + ->expectsTable( + ['Team', 'Name', 'Action', 'Servers', 'Projects', 'Members', 'Subscription ended', 'Created'], + [[$personalTeam->id, $personalTeam->name, 'remove data, keep team', 1, 1, 1, now()->subDays(31)->toDateString(), $personalTeam->created_at->toDateString()]], + ) + ->assertSuccessful(); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 0 teams and 0 users.') + ->expectsOutput('Removed the data of 1 personal team and kept the team.') + ->assertSuccessful(); + + $personalTeam->refresh(); + expect($personalTeam->personal_team)->toBeTrue() + ->and($user->teams()->pluck('teams.id')->sort()->values()->all())->toBe(collect([$personalTeam->id, $payingTeam->id])->sort()->values()->all()) + ->and($user->fresh()->current_team_id)->toBe($personalTeam->id) + ->and(DB::table('sessions')->where('id', $sessionId)->exists())->toBeTrue() + ->and(Server::withTrashed()->whereKey($server->id)->exists())->toBeFalse() + ->and($personalTeam->emailNotificationSettings->smtp_enabled)->toBeFalse() + ->and($personalTeam->emailNotificationSettings->smtp_from_address)->toBeNull() + ->and($personalTeam->webhookNotificationSettings->webhook_enabled)->toBeFalse() + ->and($personalTeam->webhookNotificationSettings->webhook_url)->toBeNull(); + $this->assertModelMissing($subscription); + $this->assertModelMissing($project); + $this->assertModelMissing($privateKey); + $this->assertModelExists($user); + $this->assertModelExists($payingServer); + expect($payingTeam->subscriptions()->count())->toBe(1); +}); + +test('it does not select a kept personal team again', function () { + ['personalTeam' => $personalTeam] = lapsedPersonalTeamOfPayingTeamMember(); + $personalTeam->forceFill(['created_at' => now()->subYear()])->save(); + endedSubscription($personalTeam); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->artisan('cloud:cleanup-unsubscribed-teams') + ->expectsOutputToContain('Found 0 teams') + ->assertSuccessful(); + $this->assertModelExists($personalTeam); +}); + +test('it deletes a lapsed team that is not personal even when its members have another team', function () { + ['user' => $user, 'personalTeam' => $sharedTeam] = lapsedPersonalTeamOfPayingTeamMember(); + $sharedTeam->forceFill(['personal_team' => false])->save(); + endedSubscription($sharedTeam); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 0 users.') + ->assertSuccessful(); + + $this->assertModelMissing($sharedTeam); + $this->assertModelExists($user); +}); + +test('a team that fails to delete does not stop the others, and its users are deleted on a later run', function () { + Notification::fake(); + $rootTeam = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'created_at' => now()->subYear()]); + $rootTeam->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + $failingOwner = User::factory()->create(); + $failingTeam = $failingOwner->teams()->first(); + endedSubscription($failingTeam); + $otherTeam = Team::factory()->create(); + endedSubscription($otherTeam); + + $this->mock(DeleteTeamData::class, function ($mock) use ($failingTeam) { + $mock->shouldReceive('handle')->andReturnUsing(function (Team $team) use ($failingTeam) { + if ($team->id === $failingTeam->id) { + throw new RuntimeException('S3 is down'); + } + + return (new DeleteTeamData)->handle($team); + }); + }); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 0 users.') + ->expectsOutput('Could not delete 1 team. See the log for details.') + ->assertFailed(); + + $this->assertModelMissing($otherTeam); + $this->assertModelExists($failingTeam); + $this->assertModelExists($failingOwner); + expect($failingOwner->teams()->whereKey($failingTeam->id)->exists())->toBeTrue(); + Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, "Could not delete 1 team:\n- team {$failingTeam->id}: S3 is down")); + + $this->forgetMock(DeleteTeamData::class); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 1 user.') + ->assertSuccessful(); + + $this->assertModelMissing($failingTeam); + $this->assertModelMissing($failingOwner); + $this->assertModelExists($rootTeam); +}); + +test('it keeps a team while Stripe has a paying subscription of its owner that Coolify does not record', function (?string $metadataTeam) { + Notification::fake(); + $rootTeam = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'created_at' => now()->subYear()]); + $rootTeam->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + endedSubscription($team, ['stripe_customer_id' => 'cus_old']); + $lookup = fakeOpenStripeSubscriptions([['id' => 'sub_orphan', 'status' => 'active', 'metadata' => $metadataTeam ? ['team_id' => $metadataTeam === 'this' ? (string) $team->id : '999999'] : []]]); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Skipped 1 team because of Stripe. See the log for details.') + ->assertSuccessful(); + + $this->assertModelExists($team); + $this->assertModelExists($owner); + $lookup->shouldHaveReceived('handle')->with(Mockery::on(fn ($emails) => $emails->all() === [$owner->email]), Mockery::on(fn ($customerIds) => $customerIds->all() === ['cus_old'])); + Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, "Skipped 1 team:\n- team {$team->id}: keep: Stripe subscription sub_orphan is active")); +})->with([ + 'without a team' => [null], + 'of this team' => ['this'], + 'of a deleted team' => ['deleted'], +]); + +test('it deletes a team when the paying Stripe subscription of its owner belongs to another team', function () { + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + endedSubscription($team); + $payingTeam = Team::factory()->create(); + $payingTeam->members()->attach($owner->id, ['role' => 'owner']); + Subscription::factory()->create(['team_id' => $payingTeam->id, 'stripe_subscription_id' => 'sub_paying', 'stripe_invoice_paid' => true]); + fakeOpenStripeSubscriptions([ + ['id' => 'sub_paying', 'status' => 'active'], + ['id' => 'sub_metadata', 'status' => 'active', 'metadata' => ['team_id' => (string) $payingTeam->id]], + ['id' => 'sub_ended', 'status' => 'unpaid'], + ]); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->with('sub_ended')->andReturn(StripeSubscription::constructFrom(['id' => 'sub_ended', 'status' => 'unpaid'])); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_ended', []); + app()->instance(StripeClient::class, $stripe); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->assertModelExists($payingTeam); + expect(Subscription::where('team_id', $team->id)->exists())->toBeFalse(); +}); + +test('it keeps a team when the Stripe check fails', function () { + $team = Team::factory()->create(); + endedSubscription($team); + fakeOpenStripeSubscriptions()->shouldReceive('handle')->andThrow(new RuntimeException('Stripe is down')); + + $this->artisan('cloud:cleanup-unsubscribed-teams') + ->expectsTable( + ['Team', 'Name', 'Action', 'Servers', 'Projects', 'Members', 'Subscription ended', 'Created'], + [[$team->id, $team->name, 'keep: Stripe check failed (Stripe is down)', 0, 0, 0, now()->subDays(31)->toDateString(), $team->created_at->toDateString()]], + ) + ->assertSuccessful(); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful(); + + $this->assertModelExists($team); +}); + +test('it cancels the open Stripe subscriptions of a team that do not pay before it deletes the team', function () { + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + $row = endedSubscription($team, ['stripe_customer_id' => 'cus_unpaid', 'stripe_subscription_id' => 'sub_unpaid']); + $otherTeam = Team::factory()->create(); + fakeOpenStripeSubscriptions([ + ['id' => 'sub_unpaid', 'status' => 'unpaid'], + ['id' => 'sub_paused', 'status' => 'paused', 'metadata' => ['team_id' => (string) $team->id]], + ['id' => 'sub_other_team', 'status' => 'unpaid', 'metadata' => ['team_id' => (string) $otherTeam->id]], + ]); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->andReturnUsing(fn (string $id) => StripeSubscription::constructFrom(['id' => $id, 'status' => 'unpaid'])); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_unpaid', []); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_paused', []); + $stripe->subscriptions->shouldNotReceive('cancel')->with('sub_other_team', []); + app()->instance(StripeClient::class, $stripe); + + $this->artisan('cloud:cleanup-unsubscribed-teams') + ->expectsTable( + ['Team', 'Name', 'Action', 'Servers', 'Projects', 'Members', 'Subscription ended', 'Created'], + [[$team->id, $team->name, 'delete team, cancel sub_unpaid, sub_paused', 0, 0, 1, now()->subDays(31)->toDateString(), $team->created_at->toDateString()]], + ) + ->assertSuccessful(); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Deleted 1 team and 1 user.') + ->assertSuccessful(); + + $this->assertModelMissing($team); + $this->assertModelMissing($row); + $this->assertModelExists($otherTeam); +}); + +test('it keeps a team when Stripe does not cancel its open subscription', function () { + Notification::fake(); + $rootTeam = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'created_at' => now()->subYear()]); + $rootTeam->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + $team = Team::factory()->create(); + endedSubscription($team, ['stripe_customer_id' => 'cus_unpaid']); + fakeOpenStripeSubscriptions([['id' => 'sub_unpaid', 'status' => 'unpaid', 'metadata' => ['team_id' => (string) $team->id]]]); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->andReturn(StripeSubscription::constructFrom(['id' => 'sub_unpaid', 'status' => 'unpaid'])); + $stripe->subscriptions->shouldReceive('cancel')->andThrow(new RuntimeException('Stripe is down')); + app()->instance(StripeClient::class, $stripe); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Could not delete 1 team. See the log for details.') + ->assertFailed(); + + $this->assertModelExists($team); + Notification::assertSentToTimes($rootTeam, GeneralNotification::class, 1); + Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, "- team {$team->id}: Could not cancel the Stripe subscription sub_unpaid.")); +}); + +test('it sends one notification for all skipped teams', function () { + Notification::fake(); + $rootTeam = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'created_at' => now()->subYear()]); + $rootTeam->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + $teams = Team::factory()->count(22)->create(); + $teams->each(fn (Team $team) => endedSubscription($team)); + fakeOpenStripeSubscriptions()->shouldReceive('handle')->andThrow(new RuntimeException('Stripe is down')); + + $this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true]) + ->expectsOutput('Skipped 22 teams because of Stripe. See the log for details.') + ->assertSuccessful(); + + Notification::assertSentToTimes($rootTeam, GeneralNotification::class, 1); + Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'Skipped 22 teams:') + && str_contains($notification->message, '- and 2 more. See the log.')); +}); diff --git a/tests/Feature/DeleteTeamDataTest.php b/tests/Feature/DeleteTeamDataTest.php new file mode 100644 index 0000000000..b7b767228e --- /dev/null +++ b/tests/Feature/DeleteTeamDataTest.php @@ -0,0 +1,241 @@ + 0]); + Storage::fake('images'); + Storage::fake('ssh-keys'); + Process::fake(); +}); + +/** + * Creates a team with at least one row in every table that holds team data. + * + * @return array{team: Team, project: Project, server: Server} + */ +function populatedTeam(User $member): array +{ + $team = Team::factory()->create(); + $team->members()->attach($member, ['role' => 'owner']); + $member->update(['current_team_id' => $team->id]); + + Subscription::factory()->create(['team_id' => $team->id]); + TeamInvitation::create(['team_id' => $team->id, 'uuid' => (string) Str::uuid(), 'email' => fake()->email(), 'role' => 'member', 'link' => 'https://example.com/invite']); + $member->tokens()->create(['name' => 'cli', 'token' => Str::random(64), 'abilities' => ['read'], 'team_id' => $team->id]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $s3 = S3Storage::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'usable', 'region' => 'us', 'key' => 'k', 'secret' => 's', 'bucket' => 'b', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true]); + S3Storage::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'unusable', 'region' => 'us', 'key' => 'k', 'secret' => 's', 'bucket' => 'b', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => false]); + GithubApp::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'gh', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $team->id, 'private_key_id' => $privateKey->id]); + GitlabApp::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'gl', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $team->id]); + CloudProviderToken::factory()->create(['team_id' => $team->id]); + $integrationToken = IntegrationToken::factory()->create(['team_id' => $team->id]); + $dnsRecord = ManagedDnsRecord::factory()->create(['dns_provider_zone_id' => DnsProviderZone::factory()->create(['integration_token_id' => $integrationToken->id])->id]); + $tag = Tag::create(['name' => 'tag-'.Str::random(6), 'team_id' => $team->id]); + + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $trashedServer = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $trashedServer->delete(); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + DockerCleanupExecution::create(['server_id' => $server->id]); + SslCertificate::forceCreate(['server_id' => $server->id, 'ssl_certificate' => 'cert', 'ssl_private_key' => 'key', 'common_name' => 'ca', 'valid_until' => now()->addYear(), 'is_ca_certificate' => true]); + + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + SharedEnvironmentVariable::forceCreate(['key' => 'TEAM', 'value' => 'x', 'type' => 'team', 'team_id' => $team->id]); + SharedEnvironmentVariable::forceCreate(['key' => 'PROJECT', 'value' => 'x', 'type' => 'project', 'team_id' => $team->id, 'project_id' => $project->id]); + SharedEnvironmentVariable::forceCreate(['key' => 'ENV', 'value' => 'x', 'type' => 'environment', 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id]); + + $onDestination = ['environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => StandaloneDocker::class]; + + $application = Application::factory()->create($onDestination); + $application->tags()->attach($tag); + ManagedDnsRecordReference::create(['managed_dns_record_id' => $dnsRecord->id, 'resource_type' => Application::class, 'resource_id' => $application->id]); + EnvironmentVariable::create(['key' => 'APP_SECRET', 'value' => 'x', 'resourceable_type' => Application::class, 'resourceable_id' => $application->id]); + $appVolume = LocalPersistentVolume::create(['name' => 'app-data-'.Str::random(6), 'mount_path' => '/data', 'resource_type' => Application::class, 'resource_id' => $application->id]); + LocalFileVolume::create(['fs_path' => '/tmp/app.env', 'mount_path' => '/app/.env', 'content' => 'SECRET=1', 'resource_type' => Application::class, 'resource_id' => $application->id]); + $volumeBackup = ScheduledVolumeBackup::forceCreate(['uuid' => (string) Str::uuid(), 'backupable_type' => LocalPersistentVolume::class, 'backupable_id' => $appVolume->id, 'team_id' => $team->id, 'frequency' => '0 0 * * *']); + ScheduledVolumeBackupExecution::forceCreate(['uuid' => (string) Str::uuid(), 'scheduled_volume_backup_id' => $volumeBackup->id, 'status' => 'success']); + $preview = ApplicationPreview::forceCreate(['uuid' => (string) Str::uuid(), 'application_id' => $application->id, 'pull_request_id' => 1, 'pull_request_html_url' => 'https://github.com/a/b/pull/1']); + $preview->delete(); + ApplicationDeploymentQueue::forceCreate(['application_id' => (string) $application->id, 'deployment_uuid' => (string) Str::uuid(), 'server_id' => $server->id, 'status' => 'finished']); + $appTask = ScheduledTask::factory()->create(['team_id' => $team->id, 'application_id' => $application->id]); + ScheduledTaskExecution::create(['scheduled_task_id' => $appTask->id, 'status' => 'success']); + + Application::factory()->create($onDestination)->delete(); + Application::factory()->create(['environment_id' => $environment->id, 'destination_id' => 999999, 'destination_type' => StandaloneDocker::class]); + + $database = StandalonePostgresql::create([...$onDestination, 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'secret', 'postgres_db' => 'db', 'image' => 'postgres:17']); + LocalFileVolume::create(['fs_path' => '/tmp/init.sql', 'mount_path' => '/docker-entrypoint-initdb.d/init.sql', 'content' => 'select 1', 'resource_type' => StandalonePostgresql::class, 'resource_id' => $database->id]); + $databaseBackup = ScheduledDatabaseBackup::create(['team_id' => $team->id, 'frequency' => '0 0 * * *', 'database_type' => StandalonePostgresql::class, 'database_id' => $database->id, 's3_storage_id' => $s3->id]); + ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $databaseBackup->id, 'status' => 'success']); + + $service = Service::factory()->create($onDestination); + $service->tags()->attach($tag); + EnvironmentVariable::create(['key' => 'SERVICE_PASSWORD', 'value' => 'x', 'resourceable_type' => Service::class, 'resourceable_id' => $service->id]); + $serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx']); + LocalPersistentVolume::create(['name' => 'svc-data-'.Str::random(6), 'mount_path' => '/data', 'resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); + LocalFileVolume::create(['fs_path' => '/tmp/nginx.conf', 'mount_path' => '/etc/nginx.conf', 'content' => 'server {}', 'resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); + ServiceApplication::create(['name' => 'old', 'service_id' => $service->id, 'image' => 'nginx'])->delete(); + $serviceDatabase = ServiceDatabase::create(['name' => 'db', 'service_id' => $service->id, 'image' => 'postgres:17']); + $serviceBackup = ScheduledDatabaseBackup::create(['team_id' => $team->id, 'frequency' => '0 0 * * *', 'database_type' => ServiceDatabase::class, 'database_id' => $serviceDatabase->id]); + ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $serviceBackup->id, 'status' => 'success']); + $serviceTask = ScheduledTask::factory()->create(['team_id' => $team->id, 'service_id' => $service->id]); + ScheduledTaskExecution::create(['scheduled_task_id' => $serviceTask->id, 'status' => 'success']); + + return ['team' => $team, 'project' => $project, 'server' => $server]; +} + +/** + * @return array + */ +function tableRowCounts(): array +{ + return collect(Schema::getTableListing(schemaQualified: false)) + ->reject(fn (string $table) => in_array($table, ['migrations', 'audit_events', 'activity_log'], true)) + ->mapWithKeys(fn (string $table) => [$table => DB::table($table)->count()]) + ->sortKeys() + ->all(); +} + +test('it deletes every record of the team and leaves other teams untouched', function () { + $member = User::factory()->create(); + $keptTeam = populatedTeam(User::factory()->create())['team']; + $baseline = tableRowCounts(); + + $doomed = populatedTeam($member); + $doomed['project']->forceFill(['icon_path' => 'project-icons/icon.png', 'icon_storage_type' => 'local'])->save(); + Storage::disk('images')->put('project-icons/icon.png', 'png'); + + app(DeleteTeamData::class)->handle($doomed['team']); + + expect(tableRowCounts())->toBe($baseline) + ->and(Storage::disk('images')->exists('project-icons/icon.png'))->toBeFalse() + ->and($member->fresh()->current_team_id)->toBeNull(); + $this->assertModelExists($keptTeam); + $this->assertModelExists($member); +}); + +/** + * Stores personal data in every notification settings row of the team. + */ +function customizeNotificationSettings(Team $team): void +{ + $team->emailNotificationSettings()->update(['smtp_enabled' => true, 'smtp_from_address' => 'owner@example.com', 'smtp_recipients' => 'owner@example.com']); + $team->discordNotificationSettings()->update(['discord_enabled' => true, 'discord_webhook_url' => encrypt('https://discord.com/api/webhooks/1/secret')]); + $team->slackNotificationSettings()->update(['slack_enabled' => true, 'slack_webhook_url' => encrypt('https://hooks.slack.com/services/secret')]); + $team->telegramNotificationSettings()->update(['telegram_enabled' => true, 'telegram_token' => encrypt('123:secret'), 'telegram_chat_id' => encrypt('42')]); + $team->pushoverNotificationSettings()->update(['pushover_enabled' => true, 'pushover_user_key' => encrypt('user-key'), 'pushover_api_token' => encrypt('api-token')]); + $team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => encrypt('https://example.com/webhook')]); +} + +/** + * The decrypted notification settings of a team, without keys and timestamps. + * + * @return array> + */ +function notificationSettingsValues(Team $team): array +{ + $team = $team->fresh(); + + return collect(['emailNotificationSettings', 'discordNotificationSettings', 'slackNotificationSettings', 'telegramNotificationSettings', 'pushoverNotificationSettings', 'webhookNotificationSettings']) + ->mapWithKeys(fn (string $relation) => [$relation => collect($team->{$relation}->getAttributes()) + ->except(['id', 'team_id', 'created_at', 'updated_at']) + ->map(fn ($value, string $column) => $team->{$relation}->{$column}) + ->all()]) + ->all(); +} + +test('purging keeps only the team, its memberships, and default notification settings', function () { + $member = User::factory()->create(); + $otherTeam = populatedTeam(User::factory()->create())['team']; + $baseline = tableRowCounts(); + + $purged = populatedTeam($member); + $team = $purged['team']; + $team->forceFill(['personal_team' => true])->save(); + customizeNotificationSettings($team); + DB::table('sessions')->insert(['id' => Str::random(40), 'user_id' => $member->id, 'payload' => '', 'last_activity' => now()->timestamp]); + + app(DeleteTeamData::class)->purge($team); + + $expected = $baseline; + foreach (['teams', 'team_user', 'sessions', 'email_notification_settings', 'discord_notification_settings', 'slack_notification_settings', 'telegram_notification_settings', 'pushover_notification_settings', 'webhook_notification_settings'] as $table) { + $expected[$table]++; + } + expect(tableRowCounts())->toBe($expected) + ->and(notificationSettingsValues($team))->toBe(notificationSettingsValues($otherTeam)) + ->and($team->fresh()->personal_team)->toBeTrue() + ->and($member->fresh()->current_team_id)->toBe($team->id) + ->and($member->teams()->whereKey($team->id)->exists())->toBeTrue(); +}); + +test('it runs nothing on the team servers and leaves the DNS records at the provider', function () { + Bus::fake([ReleaseManagedDnsRecordsJob::class]); + $doomed = populatedTeam(User::factory()->create()); + + app(DeleteTeamData::class)->handle($doomed['team']); + + Process::assertNothingRan(); + Bus::assertNotDispatched(ReleaseManagedDnsRecordsJob::class); +}); + +test('it never purges or deletes the root team', function (string $method) { + $rootTeam = Team::find(0) ?? Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]); + $server = Server::factory()->create(['team_id' => 0]); + + expect(fn () => app(DeleteTeamData::class)->{$method}($rootTeam)) + ->toThrow(LogicException::class, 'The root team (id 0) is never deleted or purged.'); + + expect(Team::find(0))->not->toBeNull() + ->and(Server::find($server->id))->not->toBeNull(); +})->with(['purge', 'handle']); diff --git a/tests/Feature/ProfileDeleteAccountTest.php b/tests/Feature/ProfileDeleteAccountTest.php index 1f6e17fc6a..b36e118021 100644 --- a/tests/Feature/ProfileDeleteAccountTest.php +++ b/tests/Feature/ProfileDeleteAccountTest.php @@ -17,6 +17,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Process; use Livewire\Livewire; use Stripe\Service\SubscriptionService; use Stripe\StripeClient; @@ -27,6 +28,7 @@ beforeEach(function () { InstanceSettings::forceCreate(['id' => 0]); $this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); $this->rootUser = User::factory()->create(['id' => 0]); + fakeOpenStripeSubscriptions(); }); function deleteAccountUser(): User @@ -138,6 +140,7 @@ it('cancels the subscription immediately before deleting the account on cloud', $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->with('sub_123')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'active'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_123', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'canceled'])); app()->instance(StripeClient::class, $stripe); @@ -159,6 +162,7 @@ it('keeps the account and subscription when immediate cancellation fails', funct $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('retrieve')->with('sub_failed')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_failed', 'status' => 'active'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_failed', [])->andThrow(new RuntimeException('Stripe unavailable')); app()->instance(StripeClient::class, $stripe); @@ -224,6 +228,7 @@ it('cancels a past due subscription and preserves a transferred team', function $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('retrieve')->with('sub_past_due')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'past_due'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_past_due', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'canceled'])); app()->instance(StripeClient::class, $stripe); @@ -309,3 +314,109 @@ it('forgets busy github runners without cleanup instead of blocking the removal ->and(GithubRunnerExecution::find($queued->id))->toBeNull() ->and(GithubRunnerConfig::find($config->id))->toBeNull(); }); + +it('lets a cloud owner whose subscription ended delete the account with the servers and projects of the personal team', function () { + config(['constants.coolify.self_hosted' => false]); + Process::fake(); + $user = deleteAccountUser(); + $team = $user->teams()->first(); + $team->update(['name' => 'Lapsed']); + Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => false]); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + + Livewire::test(ProfileIndex::class) + ->assertDontSee('Before you can delete your account') + ->assertSee('The servers, projects, and Git sources of the team "Lapsed" will be removed from Coolify.', false) + ->call('deleteAccount', 'password') + ->assertRedirect(route('login')); + + expect(User::find($user->id))->toBeNull() + ->and(Team::find($team->id))->toBeNull() + ->and(Server::withTrashed()->find($server->id))->toBeNull() + ->and(Project::find($project->id))->toBeNull(); + Process::assertNothingRan(); +}); + +it('lets a lapsed cloud owner delete the account when Stripe already ended a leftover subscription', function () { + config(['constants.coolify.self_hosted' => false]); + Process::fake(); + $user = deleteAccountUser(); + $team = $user->teams()->first(); + Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_fraud_refunded', 'stripe_invoice_paid' => false]); + Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_duplicate', 'stripe_invoice_paid' => false]); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->with('sub_fraud_refunded')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_fraud_refunded', 'status' => 'canceled'])); + $stripe->subscriptions->shouldReceive('retrieve')->with('sub_duplicate')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_duplicate', 'status' => 'unpaid'])); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_duplicate', []); + app()->instance(StripeClient::class, $stripe); + + Livewire::test(ProfileIndex::class) + ->call('deleteAccount', 'password') + ->assertRedirect(route('login')); + + expect(User::find($user->id))->toBeNull() + ->and(Team::find($team->id))->toBeNull() + ->and(Subscription::where('team_id', $team->id)->exists())->toBeFalse(); +}); + +it('still asks a cloud owner with a paying subscription to delete servers and projects first', function (array $subscription) { + config(['constants.coolify.self_hosted' => false]); + $user = deleteAccountUser(); + $team = $user->teams()->first(); + $team->update(['name' => 'Paying']); + Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_paying', ...$subscription]); + Project::factory()->create(['team_id' => $team->id]); + + Livewire::test(ProfileIndex::class) + ->assertSee('Delete all projects, servers, and Git sources of the team "Paying".', false) + ->call('deleteAccount', 'password'); + + expect(User::find($user->id))->not->toBeNull() + ->and(Team::find($team->id))->not->toBeNull(); +})->with([ + 'paid' => [['stripe_invoice_paid' => true]], + 'past due' => [['stripe_past_due' => true]], +]); + +it('cancels the Stripe subscriptions of the email that Coolify does not record, except those of a team the user does not own', function () { + config(['constants.coolify.self_hosted' => false]); + $user = deleteAccountUser(); + $otherTeam = User::factory()->create()->teams()->first(); + $otherTeam->members()->attach($user->id, ['role' => 'member']); + fakeOpenStripeSubscriptions([ + ['id' => 'sub_orphan', 'status' => 'active'], + ['id' => 'sub_other_team', 'status' => 'active', 'metadata' => ['team_id' => (string) $otherTeam->id]], + ]); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->with('sub_orphan')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_orphan', 'status' => 'active'])); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_orphan', []); + $stripe->subscriptions->shouldNotReceive('cancel')->with('sub_other_team', []); + app()->instance(StripeClient::class, $stripe); + + Livewire::test(ProfileIndex::class) + ->assertSee('The Stripe subscription sub_orphan of your email, which is not linked to a team, will be cancelled immediately.') + ->assertDontSee('sub_other_team') + ->call('deleteAccount', 'password', []) + ->assertRedirect(route('login')); + + expect(User::find($user->id))->toBeNull(); +}); + +it('keeps the account when the Stripe lookup fails', function () { + config(['constants.coolify.self_hosted' => false]); + $user = deleteAccountUser(); + $lookup = fakeOpenStripeSubscriptions(); + $lookup->shouldReceive('handle')->andThrow(new RuntimeException('Stripe unavailable')); + + Livewire::test(ProfileIndex::class) + ->assertSee('Every subscription of your teams and your email will be cancelled immediately.') + ->call('deleteAccount', 'password', []); + + // The render after the failed delete reads the cached fallback; only handle() called Stripe again. + $lookup->shouldHaveReceived('handle')->twice(); + + expect(User::find($user->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php index 0c8742d06e..2c7c4f97aa 100644 --- a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php +++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php @@ -1,10 +1,12 @@ mockSubscriptions ->shouldReceive('update') - ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + ->andThrow(new InvalidRequestException('Subscription not found')); $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); $result = $action->execute($this->team); @@ -94,3 +96,49 @@ describe('CancelSubscriptionAtPeriodEnd', function () { expect($result['error'])->toContain('Stripe error'); }); }); + +describe('CancelSubscription::cancelById', function () { + test('cancels an active subscription and ends the local row', function () { + $this->mockSubscriptions->shouldReceive('retrieve')->with('sub_test_456')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_test_456', 'status' => 'active'])); + $this->mockSubscriptions->shouldReceive('cancel')->once()->with('sub_test_456', []); + app()->instance(StripeClient::class, $this->mockStripe); + + expect(CancelSubscription::cancelById('sub_test_456'))->toBeTrue() + ->and($this->subscription->fresh()->stripe_subscription_id)->toBeNull() + ->and($this->subscription->fresh()->ended_at)->not->toBeNull(); + }); + + test('ends the local row without cancelling when Stripe already ended the subscription', function (string $status) { + $this->mockSubscriptions->shouldReceive('retrieve')->with('sub_test_456')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_test_456', 'status' => $status])); + $this->mockSubscriptions->shouldNotReceive('cancel'); + app()->instance(StripeClient::class, $this->mockStripe); + + expect(CancelSubscription::cancelById('sub_test_456'))->toBeTrue() + ->and($this->subscription->fresh()->stripe_subscription_id)->toBeNull() + ->and($this->subscription->fresh()->ended_at)->not->toBeNull(); + })->with(['canceled', 'incomplete_expired']); + + test('ends the local row when Stripe no longer has the subscription', function () { + $this->mockSubscriptions->shouldReceive('retrieve')->with('sub_test_456')->andThrow( + InvalidRequestException::factory('No such subscription', 404, null, null, null, 'resource_missing') + ); + $this->mockSubscriptions->shouldNotReceive('cancel'); + app()->instance(StripeClient::class, $this->mockStripe); + + expect(CancelSubscription::cancelById('sub_test_456'))->toBeTrue() + ->and($this->subscription->fresh()->stripe_subscription_id)->toBeNull() + ->and($this->subscription->fresh()->ended_at)->not->toBeNull(); + }); + + test('keeps the local row when Stripe fails for another reason', function (Throwable $error) { + $this->mockSubscriptions->shouldReceive('retrieve')->with('sub_test_456')->andThrow($error); + app()->instance(StripeClient::class, $this->mockStripe); + + expect(CancelSubscription::cancelById('sub_test_456'))->toBeFalse() + ->and($this->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_456') + ->and($this->subscription->fresh()->stripe_invoice_paid)->toBeTruthy(); + })->with([ + 'invalid request' => fn () => InvalidRequestException::factory('Invalid API key', 400, null, null, null, 'api_key_expired'), + 'connection' => fn () => new RuntimeException('Stripe unavailable'), + ]); +}); diff --git a/tests/Feature/Subscription/FindOpenStripeSubscriptionsTest.php b/tests/Feature/Subscription/FindOpenStripeSubscriptionsTest.php new file mode 100644 index 0000000000..55b170d480 --- /dev/null +++ b/tests/Feature/Subscription/FindOpenStripeSubscriptionsTest.php @@ -0,0 +1,46 @@ + 'list', 'data' => $objects, 'has_more' => false]); +} + +test('it finds the open subscriptions of every customer of the email and of the given customers', function () { + $stripe = Mockery::mock(StripeClient::class); + $stripe->customers = Mockery::mock(CustomerService::class); + $stripe->customers->shouldReceive('all')->with(['email' => 'Owner@Example.com', 'limit' => 100])->andReturn(stripeList([['object' => 'customer', 'id' => 'cus_a']])); + $stripe->customers->shouldReceive('all')->with(['email' => 'owner@example.com', 'limit' => 100])->andReturn(stripeList([['object' => 'customer', 'id' => 'cus_b'], ['object' => 'customer', 'id' => 'cus_a']])); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('all')->once()->with(['customer' => 'cus_given', 'limit' => 100])->andReturn(stripeList([['object' => 'subscription', 'id' => 'sub_given', 'status' => 'past_due']])); + $stripe->subscriptions->shouldReceive('all')->once()->with(['customer' => 'cus_a', 'limit' => 100])->andReturn(stripeList([['object' => 'subscription', 'id' => 'sub_a', 'status' => 'active']])); + $stripe->subscriptions->shouldReceive('all')->once()->with(['customer' => 'cus_b', 'limit' => 100])->andReturn(stripeList([ + ['object' => 'subscription', 'id' => 'sub_b', 'status' => 'unpaid'], + ['object' => 'subscription', 'id' => 'sub_expired', 'status' => 'incomplete_expired'], + ])); + app()->instance(StripeClient::class, $stripe); + + $subscriptions = app(FindOpenStripeSubscriptions::class)->handle(['Owner@Example.com', null], ['cus_given', null]); + + expect($subscriptions->keys()->all())->toBe(['sub_given', 'sub_a', 'sub_b']); +}); + +test('the team of a subscription is the team that records it, else the team in its metadata', function () { + $recordingTeam = Team::factory()->create(); + Subscription::create(['team_id' => $recordingTeam->id, 'stripe_subscription_id' => 'sub_recorded']); + + expect(FindOpenStripeSubscriptions::teamId(StripeSubscription::constructFrom(['id' => 'sub_recorded', 'metadata' => ['team_id' => '999']])))->toBe($recordingTeam->id) + ->and(FindOpenStripeSubscriptions::teamId(StripeSubscription::constructFrom(['id' => 'sub_new', 'metadata' => ['team_id' => '42']])))->toBe(42) + ->and(FindOpenStripeSubscriptions::teamId(StripeSubscription::constructFrom(['id' => 'sub_none'])))->toBeNull(); +}); diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php index f681ca4a5f..c950a4592b 100644 --- a/tests/Feature/Subscription/StripeProcessJobTest.php +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -4,6 +4,7 @@ use App\Jobs\ServerLimitCheckJob; use App\Jobs\StripeProcessJob; use App\Jobs\SubscriptionInvoiceFailedJob; use App\Jobs\VerifyStripeSubscriptionStatusJob; +use App\Models\Server; use App\Models\Subscription; use App\Models\Team; use App\Models\User; @@ -11,6 +12,13 @@ use App\Notifications\Internal\GeneralNotification; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; +use Stripe\Customer; +use Stripe\PaymentIntent; +use Stripe\Service\CustomerService; +use Stripe\Service\PaymentIntentService; +use Stripe\Service\RefundService; +use Stripe\Service\SubscriptionService; +use Stripe\StripeClient; uses(RefreshDatabase::class); @@ -409,3 +417,79 @@ test('created event fills a pending customer record without granting access', fu expect($this->team->subscription()->first()->stripe_subscription_id)->toBe('sub_pending') ->and($this->team->subscription()->first()->stripe_invoice_paid)->toBeFalsy(); }); + +test('customer.subscription.deleted ends only the deleted row of a team with duplicate rows', function () { + Server::flushIdentityMap(); + $active = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_dup', + 'stripe_subscription_id' => 'sub_active', + 'stripe_invoice_paid' => true, + ]); + $deleted = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_dup', + 'stripe_subscription_id' => 'sub_deleted', + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update(['is_usable' => true, 'is_reachable' => true]); + + (new StripeProcessJob(['type' => 'customer.subscription.deleted', 'data' => ['object' => [ + 'customer' => 'cus_dup', + 'id' => 'sub_deleted', + ]]]))->handle(); + + expect($active->fresh()->stripe_subscription_id)->toBe('sub_active') + ->and($active->fresh()->stripe_invoice_paid)->toBeTruthy() + ->and($active->fresh()->ended_at)->toBeNull() + ->and($deleted->fresh()->stripe_subscription_id)->toBeNull() + ->and($deleted->fresh()->ended_at)->not->toBeNull() + ->and($server->settings()->first()->is_usable)->toBeTruthy() + ->and($server->settings()->first()->is_reachable)->toBeTruthy(); +}); + +test('an early fraud warning refunds and cancels every subscription of the email', function () { + Server::flushIdentityMap(); + $endedRow = Subscription::create([ + 'team_id' => Team::factory()->create()->id, + 'stripe_customer_id' => 'cus_fraud', + 'stripe_subscription_id' => null, + 'stripe_invoice_paid' => false, + ]); + $subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_fraud', + 'stripe_subscription_id' => 'sub_fraud', + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings()->update(['is_usable' => true]); + $lookup = fakeOpenStripeSubscriptions([['id' => 'sub_fraud', 'status' => 'active'], ['id' => 'sub_other_customer', 'status' => 'active']]); + + $stripe = Mockery::mock(StripeClient::class); + $stripe->refunds = Mockery::mock(RefundService::class); + $stripe->refunds->shouldReceive('create')->once()->with(['charge' => 'ch_fraud']); + $stripe->paymentIntents = Mockery::mock(PaymentIntentService::class); + $stripe->paymentIntents->shouldReceive('retrieve')->with('pi_fraud', [])->andReturn(PaymentIntent::constructFrom(['id' => 'pi_fraud', 'customer' => 'cus_fraud'])); + $stripe->customers = Mockery::mock(CustomerService::class); + $stripe->customers->shouldReceive('retrieve')->with('cus_fraud')->andReturn(Customer::constructFrom(['id' => 'cus_fraud', 'email' => 'fraud@example.com'])); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('retrieve')->andReturnUsing(fn (string $id) => Stripe\Subscription::constructFrom(['id' => $id, 'status' => 'active'])); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_fraud', []); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_other_customer', []); + app()->instance(StripeClient::class, $stripe); + + (new StripeProcessJob(['type' => 'radar.early_fraud_warning.created', 'data' => ['object' => [ + 'id' => 'issfr_1', + 'charge' => 'ch_fraud', + 'payment_intent' => 'pi_fraud', + ]]]))->handle(); + + $lookup->shouldHaveReceived('handle')->with(['fraud@example.com'], ['cus_fraud']); + expect($subscription->fresh()->stripe_subscription_id)->toBeNull() + ->and($subscription->fresh()->stripe_invoice_paid)->toBeFalsy() + ->and($subscription->fresh()->ended_at)->not->toBeNull() + ->and($endedRow->fresh()->stripe_subscription_id)->toBeNull() + ->and($server->settings()->first()->is_usable)->toBeFalsy(); +}); diff --git a/tests/Feature/Subscription/SubscriptionActionsTest.php b/tests/Feature/Subscription/SubscriptionActionsTest.php index 2612e661cb..c992ceb600 100644 --- a/tests/Feature/Subscription/SubscriptionActionsTest.php +++ b/tests/Feature/Subscription/SubscriptionActionsTest.php @@ -113,6 +113,7 @@ describe('password confirmation', function () { describe('cancelImmediately with account deletion', function () { beforeEach(function () { + fakeOpenStripeSubscriptions(); $this->stripe = Mockery::mock(StripeClient::class); $this->stripe->subscriptions = Mockery::mock(SubscriptionService::class); $this->instance(StripeClient::class, $this->stripe); @@ -156,7 +157,7 @@ describe('cancelImmediately with account deletion', function () { 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(); + $team->subscriptionEnded($team->subscription); return ['success' => true, 'error' => null]; }); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php index bc0f0a6f22..42043c046e 100644 --- a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -1,19 +1,25 @@ create(); + $paying = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_paying', 'stripe_invoice_paid' => true]); + $ending = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_ending', 'stripe_invoice_paid' => true]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings()->update(['is_usable' => true]); - // Should return early without error — no NPE - $team->subscriptionEnded(); + $team->subscriptionEnded($ending); + expect($server->settings()->first()->is_usable)->toBeTruthy(); - // If we reach here, no exception was thrown - expect(true)->toBeTrue(); + $team->subscriptionEnded($paying); + expect($server->settings()->first()->is_usable)->toBeFalsy(); }); test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () { diff --git a/tests/Feature/SubscriptionEndedAtTest.php b/tests/Feature/SubscriptionEndedAtTest.php new file mode 100644 index 0000000000..a0c1bd1ddc --- /dev/null +++ b/tests/Feature/SubscriptionEndedAtTest.php @@ -0,0 +1,86 @@ +create(); + $subscription = Subscription::factory()->create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_1', + 'stripe_invoice_paid' => true, + ]); + expect($subscription->ended_at)->toBeNull(); + + Carbon::setTestNow('2026-01-10 12:00:00'); + $team->subscriptionEnded($subscription); + expect($subscription->fresh()->ended_at->toDateTimeString())->toBe('2026-01-10 12:00:00'); + + Carbon::setTestNow('2026-02-20 12:00:00'); + $team->fresh()->subscriptionEnded($subscription->fresh()); + $subscription->fresh()->update(['stripe_customer_id' => 'cus_new']); + + expect($subscription->fresh()->ended_at->toDateTimeString())->toBe('2026-01-10 12:00:00'); +}); + +test('paying again clears ended_at', function () { + $subscription = Subscription::factory()->create(['ended_at' => now()->subDays(40)]); + + $subscription->update(['stripe_invoice_paid' => true]); + + expect($subscription->fresh()->ended_at)->toBeNull(); +}); + +test('a past due subscription in dunning does not start the retention clock', function () { + $subscription = Subscription::factory()->create(['stripe_invoice_paid' => true]); + + $subscription->update(['stripe_past_due' => true]); + expect($subscription->fresh()->ended_at)->toBeNull(); + + $subscription->update(['stripe_invoice_paid' => false]); + expect($subscription->fresh()->ended_at)->toBeNull(); +}); + +test('a checkout that was started but never paid starts the retention clock', function () { + Carbon::setTestNow('2026-03-01 08:00:00'); + $team = Team::factory()->create(); + + $subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]); + $subscription->stripe_customer_id = 'cus_checkout'; + $subscription->save(); + + expect($subscription->fresh()->ended_at->toDateTimeString())->toBe('2026-03-01 08:00:00'); +}); + +test('the migration backfills ended_at from updated_at for rows that are not paying', function () { + $migration = require database_path('migrations/2026_10_19_101103_add_ended_at_to_subscriptions_table.php'); + $migration->down(); + + $team = Team::factory()->create(); + $row = fn (array $flags, string $updatedAt) => DB::table('subscriptions')->insertGetId([ + 'team_id' => $team->id, + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ...$flags, + 'created_at' => '2025-01-01 00:00:00', + 'updated_at' => $updatedAt, + ]); + $ended = $row([], '2025-05-05 05:05:05'); + $paid = $row(['stripe_invoice_paid' => true], '2025-06-06 06:06:06'); + $pastDue = $row(['stripe_past_due' => true], '2025-07-07 07:07:07'); + + $migration->up(); + + expect(DB::table('subscriptions')->where('id', $ended)->value('ended_at'))->toBe('2025-05-05 05:05:05') + ->and(DB::table('subscriptions')->where('id', $paid)->value('ended_at'))->toBeNull() + ->and(DB::table('subscriptions')->where('id', $pastDue)->value('ended_at'))->toBeNull(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 07cfd25a16..fa1810d7d0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -28,6 +28,7 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); require_once __DIR__.'/Support/BrowserTestHelpers.php'; require_once __DIR__.'/Support/ManagedDnsTestHelpers.php'; +require_once __DIR__.'/Support/StripeTestHelpers.php'; function remoteOutputSource(string $path): string { diff --git a/tests/Support/StripeTestHelpers.php b/tests/Support/StripeTestHelpers.php new file mode 100644 index 0000000000..caad1fc9b6 --- /dev/null +++ b/tests/Support/StripeTestHelpers.php @@ -0,0 +1,21 @@ +> $subscriptions + */ +function fakeOpenStripeSubscriptions(array $subscriptions = []): MockInterface +{ + $lookup = Mockery::mock(FindOpenStripeSubscriptions::class); + $lookup->shouldReceive('handle')->andReturn( + collect($subscriptions)->map(fn (array $subscription) => StripeSubscription::constructFrom($subscription))->keyBy('id') + )->byDefault(); + app()->instance(FindOpenStripeSubscriptions::class, $lookup); + + return $lookup; +}