mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
2d2bf48003
commit
63153aa8b4
30 files changed
+1918
-253
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.'));
|
||||
});
|
||||
@@ -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']);
|
||||
@@ -17,6 +17,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
@@ -27,6 +28,7 @@ beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
$this->rootUser = User::factory()->create(['id' => 0]);
|
||||
fakeOpenStripeSubscriptions();
|
||||
});
|
||||
|
||||
function deleteAccountUser(): User
|
||||
@@ -138,6 +140,7 @@ it('cancels the subscription immediately before deleting the account on cloud',
|
||||
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_123')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'active']));
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_123', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'canceled']));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
@@ -159,6 +162,7 @@ it('keeps the account and subscription when immediate cancellation fails', funct
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_failed', 'stripe_invoice_paid' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_failed')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_failed', 'status' => 'active']));
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_failed', [])->andThrow(new RuntimeException('Stripe unavailable'));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
@@ -224,6 +228,7 @@ it('cancels a past due subscription and preserves a transferred team', function
|
||||
$subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_past_due', 'stripe_invoice_paid' => false, 'stripe_past_due' => true]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_past_due')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'past_due']));
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_past_due', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'canceled']));
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
@@ -309,3 +314,109 @@ it('forgets busy github runners without cleanup instead of blocking the removal
|
||||
->and(GithubRunnerExecution::find($queued->id))->toBeNull()
|
||||
->and(GithubRunnerConfig::find($config->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('lets a cloud owner whose subscription ended delete the account with the servers and projects of the personal team', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
Process::fake();
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
$team->update(['name' => 'Lapsed']);
|
||||
Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => false]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertDontSee('Before you can delete your account')
|
||||
->assertSee('The servers, projects, and Git sources of the team "Lapsed" will be removed from Coolify.', false)
|
||||
->call('deleteAccount', 'password')
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($team->id))->toBeNull()
|
||||
->and(Server::withTrashed()->find($server->id))->toBeNull()
|
||||
->and(Project::find($project->id))->toBeNull();
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('lets a lapsed cloud owner delete the account when Stripe already ended a leftover subscription', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
Process::fake();
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_fraud_refunded', 'stripe_invoice_paid' => false]);
|
||||
Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_duplicate', 'stripe_invoice_paid' => false]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_fraud_refunded')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_fraud_refunded', 'status' => 'canceled']));
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_duplicate')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_duplicate', 'status' => 'unpaid']));
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_duplicate', []);
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->call('deleteAccount', 'password')
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($user->id))->toBeNull()
|
||||
->and(Team::find($team->id))->toBeNull()
|
||||
->and(Subscription::where('team_id', $team->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('still asks a cloud owner with a paying subscription to delete servers and projects first', function (array $subscription) {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$team = $user->teams()->first();
|
||||
$team->update(['name' => 'Paying']);
|
||||
Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_paying', ...$subscription]);
|
||||
Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertSee('Delete all projects, servers, and Git sources of the team "Paying".', false)
|
||||
->call('deleteAccount', 'password');
|
||||
|
||||
expect(User::find($user->id))->not->toBeNull()
|
||||
->and(Team::find($team->id))->not->toBeNull();
|
||||
})->with([
|
||||
'paid' => [['stripe_invoice_paid' => true]],
|
||||
'past due' => [['stripe_past_due' => true]],
|
||||
]);
|
||||
|
||||
it('cancels the Stripe subscriptions of the email that Coolify does not record, except those of a team the user does not own', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$otherTeam = User::factory()->create()->teams()->first();
|
||||
$otherTeam->members()->attach($user->id, ['role' => 'member']);
|
||||
fakeOpenStripeSubscriptions([
|
||||
['id' => 'sub_orphan', 'status' => 'active'],
|
||||
['id' => 'sub_other_team', 'status' => 'active', 'metadata' => ['team_id' => (string) $otherTeam->id]],
|
||||
]);
|
||||
$stripe = Mockery::mock(StripeClient::class);
|
||||
$stripe->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$stripe->subscriptions->shouldReceive('retrieve')->with('sub_orphan')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_orphan', 'status' => 'active']));
|
||||
$stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_orphan', []);
|
||||
$stripe->subscriptions->shouldNotReceive('cancel')->with('sub_other_team', []);
|
||||
app()->instance(StripeClient::class, $stripe);
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertSee('The Stripe subscription sub_orphan of your email, which is not linked to a team, will be cancelled immediately.')
|
||||
->assertDontSee('sub_other_team')
|
||||
->call('deleteAccount', 'password', [])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
expect(User::find($user->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the account when the Stripe lookup fails', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
$user = deleteAccountUser();
|
||||
$lookup = fakeOpenStripeSubscriptions();
|
||||
$lookup->shouldReceive('handle')->andThrow(new RuntimeException('Stripe unavailable'));
|
||||
|
||||
Livewire::test(ProfileIndex::class)
|
||||
->assertSee('Every subscription of your teams and your email will be cancelled immediately.')
|
||||
->call('deleteAccount', 'password', []);
|
||||
|
||||
// The render after the failed delete reads the cached fallback; only handle() called Stripe again.
|
||||
$lookup->shouldHaveReceived('handle')->twice();
|
||||
|
||||
expect(User::find($user->id))->not->toBeNull();
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user