fix(saas): block accepting an invite when it would orphan a paid team (#6616)

## Problem

A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.

## Root cause

Two gaps in `SaasTeamService.acceptInvitation`:

1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.

There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.

## Fix

Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*

This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.

## Verification

- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.

## Note

This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
This commit is contained in:
ConnorYoh
2026-06-11 20:10:21 +01:00
committed by GitHub
parent 33026e1a82
commit aaa2599e23
2 changed files with 55 additions and 0 deletions
@@ -72,6 +72,16 @@ public interface TeamMembershipRepository extends JpaRepository<TeamMembership,
List<TeamMembership> findByTeamIdAndRole(
@Param("teamId") Long teamId, @Param("role") TeamRole role);
/**
* Count members with a specific role in a team. Lighter than {@link #findByTeamIdAndRole} when
* only the tally is needed (e.g. last-leader checks) — avoids fetching and join-loading rows.
*
* @param teamId the team ID
* @param role the team role (LEADER or MEMBER)
* @return number of members with that role
*/
long countByTeamIdAndRole(Long teamId, TeamRole role);
/**
* Check if a user is a member of a team
*
@@ -303,6 +303,11 @@ public class SaasTeamService {
throw new IllegalStateException("Team has no available seats");
}
// Validate: accepting won't orphan a team the user leads or that has a paid plan.
// Accepting moves the user off their current team; leaveTeam already blocks the
// last leader of a team from walking away, so accept must enforce the same rule.
assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser);
// User can only be in one team . leave existing teams before joining new one
List<TeamMembership> existingMemberships =
membershipRepository.findByUserId(acceptingUser.getId());
@@ -442,6 +447,46 @@ public class SaasTeamService {
teamId);
}
/**
* Guard against silently orphaning a team when a user accepts an invite to another one.
*
* <p>{@link #acceptInvitation} moves a user to the inviting team by first leaving their current
* team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team
* must not be left memberless while still billing. {@link #leaveTeam} already refuses to let
* the last leader walk away; accept took a shortcut around that check, which let a paid team's
* leader join another team and orphan their old team together with its live subscription.
*
* <p>So: for each non-personal team the user leads as its <em>last</em> leader, block the
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
* otherwise transfer leadership first.
*
* @param user the user attempting to accept an invitation
* @throws IllegalStateException if accepting would orphan a team the user leads
*/
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
// Personal teams are deleted on accept; non-leaders leaving never orphans a team.
continue;
}
// Only reached for a non-personal team the user leads — at most one such team in the
// one-team-per-user model — so this count runs ~once, not per membership.
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
// Another leader remains, so the team keeps an owner.
continue;
}
if (hasActivePaidSubscription(team)) {
throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan"
+ " or transfer leadership before joining another team.");
}
throw new IllegalStateException(
"You are the last leader of your team. Transfer leadership before joining"
+ " another team.");
}
}
/**
* Leave team (self-removal)
*