Skip to content

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

Merged
andrasbacsai merged 13 commits into
mainfrom
cloud-team-data-cleanup
Oct 19, 2026
Merged

andrasbacsai merged 13 commits into
mainfrom
cloud-team-data-cleanup

Conversation

@andrasbacsai

Copy link
Copy Markdown
Member

Summary

  • Record when a subscription ended. A new subscriptions.ended_at column 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 from updated_at for rows that already ended. Subscription::isPaying() and Team::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.
  • New DeleteTeamData action. 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.
    • It refuses to run on the root team (id 0).
  • New FindOpenStripeSubscriptions action. 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-teams rework:
    • --months is replaced by --days. The default is constants.cloud.data_retention_days (env CLOUD_DATA_RETENTION_DAYS, 30).
    • The retention period starts at 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.
    • Teams with a paying row are never deleted.
    • Before deleting, the command checks Stripe. A paying Stripe subscription, or a failed check, keeps the team. Open subscriptions that don't pay are cancelled before the team is deleted.
    • A lapsed personal team of someone who is also in another team only loses its data; the team and its members stay.
    • Each team is deleted in its own transaction, so one failure doesn't stop the others. One summary notification is sent per run for skipped or failed teams.
    • The table now has Action, Subscription ended and Created columns.
  • Account deletion:
    • On Cloud, owners whose single-member teams have no paying subscription can delete their account. Those teams' servers, projects and Git sources are removed from Coolify; nothing is stopped on the servers.
    • Every open Stripe subscription of the user's email and of the teams they own is cancelled first, including ones Coolify has no row for. Subscriptions of teams the user doesn't own are left alone.
    • The Stripe lookup for the confirmation text is cached for 60 seconds.
  • CancelSubscription::cancelById() treats a subscription that Stripe already cancelled, expired or no longer has as cancelled, and ends the local row.
  • Early fraud warning now cancels every subscription of the customer's email, not only the first row it finds.
  • Local Horizon in Cloud mode runs all Cloud queues in the dev supervisor.

Breaking Changes

  • cloud:cleanup-unsubscribed-teams --months is removed; use --days.
  • Team::subscriptionEnded() now requires a Subscription argument.
  • With the new 30-day default, the cleanup deletes far more than the old 6-month cutoff did, including teams that never paid. Run it without --yes (dry run) before the first real run.

andrasbacsai and others added 13 commits October 19, 2026 12:12
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.
@andrasbacsai
andrasbacsai merged commit 63153aa into main Oct 19, 2026
1 check passed
@andrasbacsai
andrasbacsai deleted the cloud-team-data-cleanup branch October 19, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant