mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
## 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.