feat(cleanup): purge stale unsubscribed teams (#11668)

This commit is contained in:
Andras Bacsai
2026-10-04 12:13:41 +02:00
committed by GitHub
parent 66cc64e0d9
commit 9f7697d501
4 changed files with 289 additions and 0 deletions
@@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CleanupUnsubscribedTeams extends Command
{
protected $signature = 'cloud:cleanup-unsubscribed-teams
{--months=6 : Months since the subscription ended}
{--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';
public function handle(): int
{
if (! isCloud()) {
$this->error('This command can only be run on Coolify Cloud.');
return self::FAILURE;
}
$months = filter_var($this->option('months'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($months === false) {
$this->error('The --months option must be a positive integer.');
return self::FAILURE;
}
$cutoff = now()->subMonths($months);
$teams = $this->eligibleTeams($cutoff)
->with('subscription')
->withCount(['servers', 'projects', 'members'])
->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.');
if ($teams->isNotEmpty()) {
$this->table(
['Team', 'Name', 'Servers', 'Projects', 'Members', 'Subscription updated'],
$teams->map(fn (Team $team) => [
$team->id,
$team->name,
$team->servers_count,
$team->projects_count,
$team->members_count,
$team->subscription->updated_at?->toDateString(),
]),
);
}
if (! $this->option('yes')) {
$this->warn('Dry run only. Use --yes to delete eligible teams.');
return self::SUCCESS;
}
foreach ($teams as $team) {
DB::transaction(fn () => $this->deleteTeam($team));
}
$deletedUserCount = 0;
foreach (User::query()->whereKey($orphanedUserIds)->whereDoesntHave('teams')->lazyById(100) as $user) {
if ($user->delete()) {
$deletedUserCount++;
}
}
$this->info("Deleted {$teams->count()} ".Str::plural('team', $teams->count())." and {$deletedUserCount} ".Str::plural('user', $deletedUserCount).'.');
return self::SUCCESS;
}
/**
* Teams whose Stripe subscription ended (no subscription id, unpaid) and did not change since the cutoff.
*/
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<int, int> $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();
}
}
+3
View File
@@ -2,10 +2,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
use HasFactory;
protected $fillable = [
'team_id',
'stripe_invoice_paid',
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Subscription>
*/
class SubscriptionFactory extends Factory
{
protected $model = Subscription::class;
public function definition(): array
{
return [
'team_id' => Team::factory(),
'stripe_customer_id' => 'cus_'.$this->faker->bothify('??????????'),
'stripe_subscription_id' => null,
'stripe_invoice_paid' => false,
'stripe_cancel_at_period_end' => false,
'stripe_plan_id' => null,
'stripe_past_due' => false,
'stripe_trial_already_ended' => false,
];
}
}
@@ -0,0 +1,122 @@
<?php
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
});
function endedSubscription(Team $team, array $attributes = []): Subscription
{
return Subscription::factory()->create([
'team_id' => $team->id,
'updated_at' => now()->subMonths(7),
...$attributes,
]);
}
test('it refuses to run outside Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$team = Team::factory()->create();
endedSubscription($team);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
$this->assertModelExists($team);
});
test('it rejects a months value that is not a positive integer', function (string $months) {
$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.')
->assertFailed();
$this->assertModelExists($team);
})->with(['0', '-1', 'abc']);
test('it previews eligible teams without deleting them', function () {
$team = Team::factory()->create(['name' => 'Acme Corp']);
endedSubscription($team);
$this->artisan('cloud:cleanup-unsubscribed-teams')
->expectsOutputToContain('Found 1 team')
->expectsOutputToContain('Acme Corp')
->expectsOutput('Dry run only. Use --yes to delete eligible teams.')
->assertSuccessful();
$this->assertModelExists($team);
});
test('it deletes an eligible team with its subscription, servers, projects, and orphaned owner', function () {
$owner = User::factory()->create();
$team = $owner->teams()->first();
$subscription = endedSubscription($team);
$server = Server::factory()->create(['team_id' => $team->id]);
$trashedServer = Server::factory()->create(['team_id' => $team->id]);
$trashedServer->delete();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
->expectsOutput('Deleted 1 team and 1 user.')
->assertSuccessful();
$this->assertModelMissing($team);
$this->assertModelMissing($subscription);
$this->assertModelMissing($project);
$this->assertModelMissing($owner);
expect(Server::withTrashed()->whereKey([$server->id, $trashedServer->id])->exists())->toBeFalse();
});
test('it keeps members with another team and detaches them from the deleted team', function () {
$team = Team::factory()->create();
endedSubscription($team);
$member = User::factory()->create();
$team->members()->attach($member, ['role' => 'member']);
$member->update(['current_team_id' => $team->id]);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful();
$this->assertModelMissing($team);
$member->refresh();
expect($member->current_team_id)->toBeNull()
->and($member->teams()->count())->toBe(1);
$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);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
->expectsOutput('Deleted 0 teams and 0 users.')
->assertSuccessful();
$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]],
]);
test('it respects the months option', function () {
$team = Team::factory()->create();
endedSubscription($team, ['updated_at' => now()->subMonths(4)]);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful();
$this->assertModelExists($team);
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--months' => 3, '--yes' => true])->assertSuccessful();
$this->assertModelMissing($team);
});