Skip to content

Use the shared Pro seat release when turning a seat off - #3157

Merged
richiemcilroy merged 4 commits into
mainfrom
fix/pro-seat-toggle
Oct 25, 2026
Merged

richiemcilroy merged 4 commits into
mainfrom
fix/pro-seat-toggle

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3064. That PR added releaseProSeatSubscription and used it for the two org-removal paths. Every other path that takes a Pro seat away still had its own copy of the old logic.

What was wrong

toggleProSeat (dashboard) and the agent API updateOrganizationMemberSeat handler, when turning a seat off:

  • picked one remaining seat with .limit(1), with no ordering;
  • if the owner of that org had no stripeSubscriptionId, set thirdPartyStripeSubscriptionId to null.

So a user who still had a manager-sponsored seat lost Pro. A user with a sponsored seat and a paid seat could also lose Pro, depending on which row came back first. The remaining-seat reads were also plain reads, so a concurrent seat change could be missed.

Two more paths removed seats without going through the shared logic:

  • invite decline only cleared the pointer when no seat was left. It never moved the pointer onto a paid remaining seat, and it read the membership without a lock;
  • organization deletion deleted members with Pro seats and never released their Pro at all.

Changes

  • Moved releaseProSeatSubscription to @cap/database/pro-seat-subscription so packages/web-backend can use it too. It now locks the user row with .for("update") before it reads the remaining seats. That serializes releases for one user. Locking the remaining seat rows instead would deadlock two orgs turning off the same user's seats at once. Every removal path now deletes or turns off the seat and calls the helper before its first plain read. Under REPEATABLE READ, an earlier plain read pins the snapshot, and the helper would miss a release that committed while it waited on the user lock. Remove-member (both the action and the agent API), invite decline and org deletion were reordered for this. The final state of each is unchanged.
  • Turning a seat off, in the dashboard action and the agent API, now does the same as removal: set hasProSeat: false, then call the helper.
  • Invite decline locks the membership row and calls the helper after the delete.
  • Organization deletion locks the member rows and calls the helper for every member that had a Pro seat, after the org's rows are gone. Users are released in sorted id order.

Unchanged:

  • turning a seat on, including the seat count check and the seat provider pointer;
  • the early return when the seat is already off;
  • turning off a user's only seat, which still clears Pro.

No Stripe calls are touched.

Tests

  • New toggle-pro-seat.test.ts covers turning a seat off with:

    • no other seat;
    • a manager-sponsored seat remaining;
    • a paid seat remaining;
    • mixed seats;
    • the row locks;
    • the seat already off.

    It also covers turning a seat on: assign, out of seats, and no active subscription. On the old code, the sponsored, mixed and lock cases fail.

  • invite-decline.test.ts mock updated for the locked membership read.

  • New invite-decline-pro-seat.test.ts covers declining with a Pro seat: last seat, manager-sponsored seat remaining, paid seat remaining, and the row locks.

  • toggle-pro-seat, invite-decline-pro-seat, remove-organization-member (from fix(web): revoke Pro access when removing the last organization seat #3064) and invite-decline are added to the CI web test list.

RetriggerView in GreptileConfidence Score: 5/5

No new blocking findings were added, and both numbered previous findings are addressed.

Summary

Routes Pro seat removal through the shared database helper.

  • Turning off a Pro seat keeps the right remaining seat attached.
  • Declining an invite releases its Pro seat correctly.
  • Deleting an organization releases its members' Pro seats.
  • Member removal releases Pro before later cleanup reads.

Both numbered findings from the previous review are addressed. No new comments were added.

Reviews (3) · Last reviewed commit: "fix(web): release Pro seats before the f..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 25, 2026 3:31am UTC

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

Open in Capy

),
);
)
.for("update");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent seat removals can fail

When two organizations turn off the same user's seats at once, each transaction first locks its own membership row. This new locking read then needs the row held by the other transaction. Each can wait for the other, so MySQL aborts one transaction. toggleProSeat does not retry, leaving one seat enabled and returning an error.

Serialize changes for the same user before locking individual memberships, or retry the whole transaction after a deadlock.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/pro-seat-subscription.ts
Line: 30

Comment:
**Concurrent seat removals can fail**

When two organizations turn off the same user's seats at once, each transaction first locks its own membership row. This new locking read then needs the row held by the other transaction. Each can wait for the other, so MySQL aborts one transaction. `toggleProSeat` does not retry, leaving one seat enabled and returning an error.

Serialize changes for the same user before locking individual memberships, or retry the whole transaction after a deadlock.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

},
]);
mockDb.limit.mockResolvedValueOnce([]);
mockDb.limit.mockReturnValueOnce(mockDb);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invite tests skip Pro release

The updated test still returns no membership, so it never reaches the new releaseProSeatSubscription call. These tests can pass even if declining an invite fails to move Pro onto a remaining paid seat.

Add a case with hasProSeat: true and a remaining paid seat. Extend the mock to support the helper's joins, then assert that the subscription changes while the invite and membership are deleted.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/invite-decline.test.ts
Line: 157

Comment:
**Invite tests skip Pro release**

The updated test still returns no membership, so it never reaches the new `releaseProSeatSubscription` call. These tests can pass even if declining an invite fails to move Pro onto a remaining paid seat.

Add a case with `hasProSeat: true` and a remaining paid seat. Extend the mock to support the helper's joins, then assert that the subscription changes while the invite and membership are deleted.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@greptile-apps

This comment has been minimized.

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

Open in Capy

Comment on lines +273 to +281
const proSeatUserIds = [
...new Set(
memberRows
.filter((member) => member.hasProSeat)
.map((member) => member.userId),
),
].sort();
for (const proSeatUserId of proSeatUserIds) {
await releaseProSeatSubscription(tx, proSeatUserId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Org deletion locks user rows in inconsistent order across its two loops

Medium Severity

The real defect is the unsorted affectedUserIds update loop in @packages/web-backend/src/Organisations/index.ts (lines 284-317): its per-user update(Db.users) takes user-row X locks in insertion order (org owner first, then membership-row order), while the new sorted release loop above it locks the same rows in ascending id order through the SELECT ... FROM users ... FOR UPDATE in releaseProSeatSubscription. With two concurrent organization deletions sharing two users whose orders oppose — org A has Pro-seat members U and V so its release loop locks U then V, while org B's affectedUserIds lists V before U (V joined first, or V is the org owner) — transaction 1 holds U and waits for V's lock, and transaction 2's affected-user update holds V and waits for U, forming an InnoDB deadlock; one deletion transaction is rolled back and the whole delete fails. This flow purges the org's S3 media before the DB transaction, so the failed deletion leaves the organization and its video rows alive with their files already deleted. The base had no seat release in this flow at all, so the sorted user-row locking here is new. Fix by making the whole transaction acquire user rows in one global order: sort affectedUserIds the same way as proSeatUserIds, or take all needed user-row locks up front in sorted order before either loop.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit 2169a6a into main Oct 25, 2026
44 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — 697c4098 Deployed Oct 25, 2026 by vercel[bot]
Preview – cap-status — 697c4098 Deployed Oct 25, 2026 by vercel[bot]
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