diff --git a/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php new file mode 100644 index 0000000000..388f82c513 --- /dev/null +++ b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php @@ -0,0 +1,135 @@ +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 $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(); + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index b0fec64f9b..3b3432224b 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -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', diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php new file mode 100644 index 0000000000..800272160e --- /dev/null +++ b/database/factories/SubscriptionFactory.php @@ -0,0 +1,29 @@ + + */ +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, + ]; + } +} diff --git a/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php new file mode 100644 index 0000000000..67a3351a72 --- /dev/null +++ b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php @@ -0,0 +1,122 @@ +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); +});