From aaa2599e23d351672746074f35ac3cf321dc4bd1 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:10:21 +0100 Subject: [PATCH] fix(saas): block accepting an invite when it would orphan a paid team (#6616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../repository/TeamMembershipRepository.java | 10 +++++ .../saas/service/SaasTeamService.java | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java index d88d96cfc..36c04f09c 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java @@ -72,6 +72,16 @@ public interface TeamMembershipRepository extends JpaRepository 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 * diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 02bf87e27..43637c2d0 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -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 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. + * + *

{@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. + * + *

So: for each non-personal team the user leads as its last 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) *