Repository navigation
fix(cloud): rework unsubscribed team cleanup and account deletion - #12210
Merged
Merged
Conversation
Add subscriptions.ended_at. The Subscription model sets it once when the row stops paying and clears it when the row pays again, so other writes to the row (which bump updated_at) no longer move the retention clock. Past-due rows count as paying. Existing unpaid rows are backfilled from updated_at. Add constants.cloud.data_retention_days (default 30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add DeleteTeamData, which deletes a team with every record Coolify keeps for it: services with their parts, soft-deleted resources and resources without a destination, file and persistent volumes, previews, backup and scheduled task executions, docker cleanup executions, unusable S3 storages, tokens, DNS records, settings, and project icons. Previews are deleted with a query because their model hook runs commands on the server. Nothing runs on the team's servers. DeleteUserAccount now uses it for the user's single-member teams, and DeleteTeamlessUser for the user record, so both paths share one implementation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cleanup cloud:cleanup-unsubscribed-teams now decides per team instead of per subscription row: a team with any paid or past-due row is kept. A team is eligible when every row ended (ended_at) before the cutoff, or when it never had a Stripe customer, is older than the cutoff, and none of its members belongs to another team. Rows that keep a Stripe subscription id while unpaid (refunds, paused, incomplete_expired) are no longer skipped. The cutoff is --days, defaulting to constants.cloud.data_retention_days (30), and replaces --months. Each team is deleted with DeleteTeamData together with its members that have no other team, in one transaction that re-checks eligibility. A failing team is logged and reported with an internal notification, and the run continues with the next team. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Coolify Cloud, a user whose single-member teams have no paid or past-due subscription cannot open the project and server pages, so the profile page could never satisfy the account deletion blockers. For these users the deletion now removes the servers, projects, and Git sources of those teams (nothing is stopped on the servers), and the confirmation lists the affected teams. Owners of a paying team keep the existing blockers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete the managed DNS references before the resources, so deleting a team's resources does not queue jobs that remove records at the team's DNS provider. Team data deletion makes no calls to customer infrastructure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
subscriptionEnded() ended the team's first row when no row was given, so a customer.subscription.deleted event for a duplicate row could end the team's active row and leave the cancelled one untouched. Every caller now passes the row it acts on, and the parameter is required. The servers of a team are disabled only when no row of the team still pays. SyncStripeSubscriptions deletes a stale row before it ends the team's inactive row, so the stale paid flag does not keep servers usable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cription The early fraud warning path cancelled the subscription at Stripe but kept its id, so a later account deletion failed when Stripe refused to cancel it again. It now ends the row it cancelled. cancelById retrieves the subscription first. A subscription that is canceled or incomplete_expired, or that Stripe reports as resource_missing, is not cancelled again; its local row is ended and the call succeeds. Other errors still fail. Account deletion cancels every row of an owned team that still has a Stripe subscription id, not one arbitrary row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anup When a personal team stops paying and one of its members also belongs to another team, cloud:cleanup-unsubscribed-teams now removes only the team's data. The team row, its memberships, and notification settings reset to the defaults of a new team stay, so the user keeps a personal team. Sessions and the current team of members are not touched. Other eligible teams are still deleted together with members left without a team. DeleteTeamData gains purge(), which handle() calls before it removes the memberships and the team. Team exposes the notification defaults and the deleting-hook cleanup as methods, so purge() reuses them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Find open Stripe subscriptions across customers by email and use them to protect teams during cleanup, account deletion, and fraud response. Route Cloud queues through the local Horizon supervisor.
Retry and throttle Stripe lookups, cache account deletion fallbacks, and report team cleanup skips and failures with grouped reasons.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
subscriptions.ended_atcolumn is set when a row stops paying and cleared when it pays again. Past due counts as paying because the subscription is still in dunning. The migration fills it fromupdated_atfor rows that already ended.Subscription::isPaying()andTeam::subscriptions()are new.Team::subscriptionEnded()now requires the subscription row it ends. Every caller passes the row it acts on: webhooks, verify job, refund, cancel, fix and sync commands. Servers are only marked unusable once no other row of the team still pays.DeleteTeamDataaction. It removes every record Coolify keeps for a team: servers, resources (including soft-deleted ones and ones without a destination), projects, backups, tasks, volumes, env vars, tokens, sources, S3 storages, settings and subscription. It runs nothing on the servers and does not release the team's DNS records at the provider.purge()keeps the team, its members and default notification settings.handle()also deletes the team.FindOpenStripeSubscriptionsaction. It lists the Stripe subscriptions that have not ended for the given emails (matched case-insensitively) and customer IDs. This catches subscriptions that have no row in Coolify.cloud:cleanup-unsubscribed-teamsrework:--monthsis replaced by--days. The default isconstants.cloud.data_retention_days(envCLOUD_DATA_RETENTION_DAYS, 30).ended_at. Teams that never paid count from when the team was created, and are only eligible if none of their members belongs to another team.CancelSubscription::cancelById()treats a subscription that Stripe already cancelled, expired or no longer has as cancelled, and ends the local row.Breaking Changes
cloud:cleanup-unsubscribed-teams --monthsis removed; use--days.Team::subscriptionEnded()now requires aSubscriptionargument.--yes(dry run) before the first real run.