Skip to content

Fix member-seat double-count; show an upgrade prompt at the cap - #1158

Merged
RhysSullivan merged 1 commit into
mainfrom
fix/invite-seat-limit-message
Jun 27, 2026
Merged

Fix member-seat double-count; show an upgrade prompt at the cap#1158
RhysSullivan merged 1 commit into
mainfrom
fix/invite-seat-limit-message

Conversation

@RhysSullivan

@RhysSullivanRhysSullivan commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

What

A free org with 1 member and 1 pending invite was refused new invites even though the plan allows up to 3 members, and the refusal showed only "Failed to send invitation. Please try again."

Root cause: seat usage was listOrganizationMemberships().length + listInvitations().length. WorkOS returns an unaccepted invite as BOTH a pending organization membership AND a pending invitation for the same person, so every outstanding invite was counted twice. A 1-member org with 1 pending invite read as 3 seats used and could not invite a third.

Change

  • countSeatsUsed (plans.ts) dedupes: active members plus the larger of pending memberships or pending invitations, never the sum. Unit-tested in plans.test.ts, including the exact reported case (1 active + 1 pending membership + 1 invitation = 2, not 3).
  • At the seat limit, "Invite member" stays clickable and opens an upgrade prompt ("You are at your member limit") with an Upgrade plan button linking to billing/plans. Seat usage ("N of M seats used") is shown next to the button.
  • The invite endpoint's 403 still carries a human-readable reason, and the invite dialog surfaces it instead of the generic retry copy.

Recording

A free org fills its 3 member seats (each invited person appears as a pending "Invited" member); at the cap, Invite member opens the upgrade prompt, and its CTA lands on the billing plans page.

recording

Notes

  • The free member-seat cap (3) is unchanged.
  • The e2e patches @executor-js/emulate so the WorkOS emulator creates a pending membership on invite, matching real WorkOS (without it the emulator only stored an invitation row, so invited users never rendered). The upstream fix is WorkOS: create a pending membership when an invitation is sent emulate#3; drop the patch once executor consumes a published emulate with that behavior. The publish is currently blocked by a separate emulate release-pipeline issue (manifest declares unpublished bundled deps, and CI npm auth fails).

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 27, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
executor-marketing4839777Commit Preview URL

Branch Preview URL
Jun 27 2026, 09:27 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 27, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
✅ Deployment successful!
View logs
executor-cloud4839777Jun 27 2026, 09:27 PM

@RhysSullivan
RhysSullivanforce-pushed the fix/invite-seat-limit-message branch from 0c6b5ab to cd615c0CompareJune 27, 2026 20:52
@pkg-pr-new

pkg-pr-newBot commented Jun 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1158

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1158

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1158

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1158

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1158

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1158

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1158

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1158

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1158

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1158

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1158

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1158

executor

npm i https://pkg.pr.new/executor@1158

commit: 4839777

@github-actions

github-actionsBot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@RhysSullivan
RhysSullivanforce-pushed the fix/invite-seat-limit-message branch from cd615c0 to aa059b8CompareJune 27, 2026 21:01
@RhysSullivanRhysSullivan changed the title Surface the real reason when a member invite is refusedSurface the real reason when a member invite is refused, and show pending invitesJun 27, 2026
@RhysSullivan
RhysSullivanforce-pushed the fix/invite-seat-limit-message branch from aa059b8 to d81b22cCompareJune 27, 2026 21:17
@RhysSullivanRhysSullivan changed the title Surface the real reason when a member invite is refused, and show pending invitesFix member-seat double-count; disable Invite at the cap with a reasonJun 27, 2026
A free org with 1 member and 1 pending invite was refused new invites even
though the plan allows up to 3 members. The seat count summed
listOrganizationMemberships (which returns the invited user as a pending
membership) and listInvitations (the same invited user), so every outstanding
invite was counted twice and the org hit the limit early.
- countSeatsUsed (plans.ts) dedupes: active members plus the larger of pending
memberships or pending invitations, never the sum; unit-tested
- at the seat limit, "Invite member" opens an upgrade prompt (cloud links it to
billing/plans) instead of the invite form, with seat usage shown next to it
- the invite endpoint's 403 still carries a human-readable reason, and the
dialog surfaces it instead of a generic "try again"
- e2e: a free org fills its 3 seats, the upgrade prompt opens, its CTA lands on
the plans page, and a direct invite is refused with a reason
@RhysSullivan
RhysSullivanforce-pushed the fix/invite-seat-limit-message branch from d81b22c to 4839777CompareJune 27, 2026 21:25
@RhysSullivanRhysSullivan changed the title Fix member-seat double-count; disable Invite at the cap with a reasonFix member-seat double-count; show an upgrade prompt at the capJun 27, 2026
@RhysSullivan
RhysSullivan marked this pull request as ready for review June 27, 2026 23:14
@RhysSullivan
RhysSullivan merged commit 958739c into mainJun 27, 2026
15 checks passed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

This PR fixes a WorkOS double-count bug where every outstanding invite was tallied twice (once as a pending organization membership, once as a pending invitation), causing free orgs to be refused new invites below the advertised 3-seat cap. The fix also replaces the generic "Failed to send invitation. Please try again." error with a clear upgrade prompt and a server-provided reason at the limit.

  • countSeatsUsed in plans.ts deduplicates with active + Math.max(pendingMembers, pendingInvitationCount), replacing the naive sum; six unit tests lock the exact reported case and boundary conditions.
  • At the seat cap, the "Invite member" button now opens an UpgradeDialog with an upgrade CTA (cloud-injected upgradeAction prop); the server's 403 carries a typed reason that the invite dialog surfaces instead of the generic copy, as defense-in-depth for stale UI state.
  • The e2e scenario drives a full free-org fill-to-cap flow and verifies the endpoint's 403 body names the real reason.

Confidence Score: 4/5

Safe to merge. The core seat-counting fix is well-tested and the UI changes are additive with correct guard logic.

The countSeatsUsed deduplication is correct for documented WorkOS behavior and is thoroughly unit-tested. The UI upgrade-prompt flow is straightforward. The e2e test has five unconditional waitForTimeout sleeps that violate the project's 'no sleeps' guideline, which can cause occasional flakiness without any functional correctness risk to production code.

The new e2e test (e2e/cloud/member-invite-seat-limit.test.ts) uses explicit sleep calls; plans.ts has an implicit overlap assumption in countSeatsUsed worth documenting.

Important Files Changed

FilenameOverview
apps/cloud/src/extensions/billing/plans.tsAdds countSeatsUsed with active + Math.max(pendingMembers, pendingInvitationCount) deduplication; correct for WorkOS's documented overlap behavior, with one implicit assumption about the two sets not having disjoint members simultaneously.
apps/cloud/src/extensions/billing/plans.test.tsNew unit tests cover the bug case, the emulator scenario, at-cap, no-invites, inactive memberships, and cleared-invitation cases — comprehensive coverage of the new function.
apps/cloud/src/account/workos-account-service.tsSwitches seat counting to countSeatsUsed and surfaces a typed error message at the cap so the UI can distinguish a plan-limit 403 from a transient failure.
packages/react/src/pages/org.tsxAdds seat-usage display, UpgradeDialog shown at the cap, and surfaces server-provided error messages in the invite dialog via messageFromExit; clean implementation with correct guard logic.
apps/cloud/src/routes/app/org.tsxPasses the cloud-specific upgradeAction (a link to billing/plans) into SharedOrgPage; minimal and correct change.
e2e/cloud/member-invite-seat-limit.test.tsNew cloud-only e2e scenario verifying the upgrade prompt at the cap and endpoint refusal with a reason; scenario logic is correct but uses 5 waitForTimeout sleep calls that violate the project's 'no sleeps' guideline.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as OrgPage (React)
participant API as inviteMember endpoint
participant WOS as WorkOS
UI->>API: GET listMembers
API->>WOS: listOrgMembers + listPendingInvitations
WOS-->>API: memberships (active + pending) + invitations
API-->>UI: "seats { used: countSeatsUsed(...), granted, unlimited }"
alt "seats.used < seats.granted"
UI->>UI: Invite member opens InviteDialog
UI->>API: POST /members/invite
API->>WOS: reserveMemberSlot check
WOS-->>API: ok
API-->>UI: 200 Invited
else "seats.used >= seats.granted (atSeatLimit)"
UI->>UI: Invite member opens UpgradeDialog
UI->>UI: User clicks Upgrade plan, navigates to /billing/plans
end
note over API: Defense-in-depth if stale UI bypasses prompt
API->>WOS: reserveMemberSlot check
WOS-->>API: over limit
API-->>UI: 403 AccountForbidden with message
UI->>UI: InviteDialog shows server message, not generic retry copy
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as OrgPage (React)
participant API as inviteMember endpoint
participant WOS as WorkOS
UI->>API: GET listMembers
API->>WOS: listOrgMembers + listPendingInvitations
WOS-->>API: memberships (active + pending) + invitations
API-->>UI: "seats { used: countSeatsUsed(...), granted, unlimited }"
alt "seats.used < seats.granted"
UI->>UI: Invite member opens InviteDialog
UI->>API: POST /members/invite
API->>WOS: reserveMemberSlot check
WOS-->>API: ok
API-->>UI: 200 Invited
else "seats.used >= seats.granted (atSeatLimit)"
UI->>UI: Invite member opens UpgradeDialog
UI->>UI: User clicks Upgrade plan, navigates to /billing/plans
end
note over API: Defense-in-depth if stale UI bypasses prompt
API->>WOS: reserveMemberSlot check
WOS-->>API: over limit
API-->>UI: 403 AccountForbidden with message
UI->>UI: InviteDialog shows server message, not generic retry copy
Loading

Reviews (1): Last reviewed commit: "Fix member-seat double-count; show an up..." | Re-trigger Greptile

Comment on lines +62 to +71
const submitInvite = async (email: string) => {
await page.getByRole("button", { name: "Invite member" }).click();
const dialog = page.getByRole("dialog");
await dialog.waitFor();
await page.waitForTimeout(500);
await dialog.getByPlaceholder("colleague@company.com").fill(email);
await page.waitForTimeout(800);
await dialog.getByRole("button", { name: "Send invite" }).click();
await dialog.waitFor({ state: "hidden", timeout: 15_000 });
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2Explicit sleeps in e2e test

e2e/AGENTS.md states "Keep it deterministic: no sleeps; wait on conditions." This function has two unconditional waitForTimeout calls (500 ms before filling the input, 800 ms before clicking Send) where condition-based waits would work: wait for the input to be enabled before filling, and wait for the button to be enabled before clicking. The test has 5 waitForTimeout calls in total (lines 66, 68, 81, 94, 98), all of which can silently become either too short on a slow CI machine or waste time on a fast one.

Context Used: e2e/AGENTS.md (source)

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!

Comment on lines +97 to +104
export const countSeatsUsed = (
memberships: ReadonlyArray<SeatMembership>,
pendingInvitationCount: number,
): number => {
const active = memberships.filter((m) => m.status === "active").length;
const pendingMembers = memberships.filter((m) => m.status === "pending").length;
return active + Math.max(pendingMembers, pendingInvitationCount);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2Math.max deduplication assumes the two sets always overlap

The fix is correct for the documented WorkOS behavior (every invitation produces exactly one pending membership). However, the Math.max approach undercounts if the two lists ever contain different people simultaneously: e.g., user A is a pending membership whose invitation was already cleared, AND user B has a pending invitation whose membership hasn't appeared yet. In that state pendingMembers = 1, pendingInvitationCount = 1, so max(1, 1) = 1, but 2 seats are actually occupied. A comment documenting that correctness depends on this overlap invariant would make the assumption explicit and help future maintainers.

Sign up for freeto 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

@RhysSullivan