fix(cloud): rework unsubscribed team cleanup and account deletion (#12210)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 authored and GitHub committed 2026-10-19 17:32:16 +02:00
1 parent 2d2bf48003
commit 63153aa8b4
30 files changed
+1918 -253

No files matched your search

+32 -23
View File
@@ -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);
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Subscription;
use Illuminate\Support\Collection;
use Stripe\Stripe;
use Stripe\StripeClient;
use Stripe\Subscription as StripeSubscription;
/**
* Finds the Stripe subscriptions that did not end, of every Stripe customer with one of the given
* emails and of the given customers. One email can have more than one Stripe customer, and a
* subscription can exist at Stripe without a subscription row in Coolify.
*/
class FindOpenStripeSubscriptions
{
/** Pause after each list request, so a long cleanup run stays under the Stripe rate limit. */
private const REQUEST_DELAY_MICROSECONDS = 50_000;
public const PAYING_STATUSES = [
StripeSubscription::STATUS_ACTIVE,
StripeSubscription::STATUS_TRIALING,
StripeSubscription::STATUS_PAST_DUE,
];
/**
* @param iterable<?string> $emails
* @param iterable<?string> $customerIds
* @return Collection<string, StripeSubscription> 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<?string> $emails
* @param Collection<int, string> $customerIds
* @return Collection<string, StripeSubscription>
*/
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;
}
}
+1 -1
View File
@@ -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}");
@@ -123,11 +123,12 @@ class SyncStripeSubscriptions
}
if ($resolution === 'delete_stale') {
$subscription->delete();
if (! $validReplacement && $inactiveSubscription && $team) {
$team->subscriptionEnded($inactiveSubscription);
}
$subscription->delete();
$fixedCount++;
continue;
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace App\Actions\Team;
use App\Actions\Service\DeleteService;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\DnsProviderZone;
use App\Models\DockerCleanupExecution;
use App\Models\EmailNotificationSettings;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
use App\Services\ProjectIconStorageService;
use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use LogicException;
/**
* Removes every record Coolify keeps for a team: servers, resources (also soft-deleted ones and
* ones without a destination), projects, keys, sources, storages, tokens, settings, and the
* subscription. Nothing runs on the team's servers; their containers keep running.
*
* purge() keeps the team and its members; handle() also deletes the team. Users are never
* deleted. Callers decide what happens to members without another team.
*/
class DeleteTeamData
{
private const NOTIFICATION_SETTINGS_MODELS = [
EmailNotificationSettings::class,
DiscordNotificationSettings::class,
SlackNotificationSettings::class,
TelegramNotificationSettings::class,
PushoverNotificationSettings::class,
WebhookNotificationSettings::class,
];
/**
* Deletes the team with its records and memberships.
*
* @return array{servers: int, resources: int, projects: int}
*/
public function handle(Team $team): array
{
return DB::transaction(function () use ($team): array {
$counts = $this->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<int, int> $environmentIds
* @return EloquentCollection<int, Model>
*/
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<int, Model> $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<int, int> $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();
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\User;
use App\Models\OauthIdentity;
use App\Models\User;
use App\Models\UserChangelogRead;
use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Deletes a user that belongs to no team anymore, with their avatar, sign-in identities,
* sessions, and password reset tokens.
*/
class DeleteTeamlessUser
{
public function handle(User $user): void
{
try {
app(AvatarStorageService::class)->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();
}
}
+97 -68
View File
@@ -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<string, ?Team> 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<int, string>
*/
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.'];
}
}));
}
}
@@ -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<int, string> $skippedTeams team id => reason */
$skippedTeams = [];
/** @var array<int, string> $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<string>}
*/
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<string>} $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<int, string> $skippedTeams
* @param array<int, string> $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<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();
->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'))));
}
}
@@ -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);
}
/**
+16 -10
View File
@@ -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}");
@@ -85,7 +85,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
$team->subscriptionEnded($this->subscription);
}
break;
+6 -3
View File
@@ -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()),
]);
}
}
+1 -1
View File
@@ -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]);
+21
View File
@@ -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);
+74 -47
View File
@@ -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);
+5
View File
@@ -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',
+10 -5
View File
@@ -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],
],
];
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->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');
});
}
};
@@ -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);
@@ -1,23 +1,36 @@
<?php
use App\Actions\Team\DeleteTeamData;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Internal\GeneralNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
use Stripe\Subscription as StripeSubscription;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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.'));
});
+241
View File
@@ -0,0 +1,241 @@
<?php
use App\Actions\Team\DeleteTeamData;
use App\Jobs\ReleaseManagedDnsRecordsJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\CloudProviderToken;
use App\Models\DnsProviderZone;
use App\Models\DockerCleanupExecution;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Subscription;
use App\Models\Tag;
use App\Models\Team;
use App\Models\TeamInvitation;
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 Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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<string, int>
*/
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<string, array<string, mixed>>
*/
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']);
+111
View File
@@ -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 &quot;Lapsed&quot; 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 &quot;Paying&quot;.', 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();
});
@@ -1,10 +1,12 @@
<?php
use App\Actions\Stripe\CancelSubscription;
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
@@ -85,7 +87,7 @@ describe('CancelSubscriptionAtPeriodEnd', function () {
test('handles stripe API error gracefully', function () {
$this->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'),
]);
});
@@ -0,0 +1,46 @@
<?php
use App\Actions\Stripe\FindOpenStripeSubscriptions;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Collection;
use Stripe\Service\CustomerService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
use Stripe\Subscription as StripeSubscription;
uses(RefreshDatabase::class);
function stripeList(array $objects): Collection
{
return Collection::constructFrom(['object' => '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();
});
@@ -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();
});
@@ -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];
});
@@ -1,19 +1,25 @@
<?php
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('subscriptionEnded does not throw when team has no subscription', function () {
test('subscriptionEnded keeps servers usable until no row of the team pays', function () {
Server::flushIdentityMap();
$team = Team::factory()->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 () {
+86
View File
@@ -0,0 +1,86 @@
<?php
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
afterEach(function () {
Carbon::setTestNow();
});
test('ending a subscription sets ended_at once and later ends do not move it', function () {
$team = Team::factory()->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();
});
+1
View File
@@ -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
{
+21
View File
@@ -0,0 +1,21 @@
<?php
use App\Actions\Stripe\FindOpenStripeSubscriptions;
use Mockery\MockInterface;
use Stripe\Subscription as StripeSubscription;
/**
* Makes FindOpenStripeSubscriptions return the given Stripe subscriptions instead of calling Stripe.
*
* @param list<array<string, mixed>> $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;
}