mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
+10
@@ -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)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user