Skip to content

feat(den): one place for an organization's plan and seats - #6516

Merged
benjaminshafii merged 5 commits into
devfrom
feat/one-org-plan
Oct 27, 2026
Merged

benjaminshafii merged 5 commits into
devfrom
feat/one-org-plan

Conversation

@benjaminshafii

@benjaminshafii benjaminshafii commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

What is this about?

Den admin and billing: the OpenWork team now sets an organization's plan (Free, Team or Enterprise) and how many users it allows in one place, /admin › organization › Billing. Invitations, SSO and SCIM all enforce that number, and the customer Billing page shows the same plan.

What problem does it solve?

An Enterprise trial set to "Enterprise, 20 seats" in /admin was blocked at 5 users with "Upgrade to the Team plan". Four different settings decided whether an org could add people, and they disagreed. "Seats" was stored but never enforced. The Free limit was edited somewhere else. The enterprisePlan flag only lifted the limit for email invites, and SSO/SCIM had their own exemption.

What was the situation before?

  • /admin: a per-row "Manage" panel ("Save access"), a separate "Free plan up to N" modal, and a flag-gated Control panel.
  • Members of an admin-set Enterprise org: "Upgrade to the Team plan" at user 6.
  • Billing page: showed the real plan only when teamPlan was on.

How is this implemented?

  • One rule. A pure resolver in ee/apps/den-api/src/organization-plan.ts (unit tested), with an I/O wrapper in organization-plan-state.ts. Users = joined members + unexpired invitations. The limit is:
    • no limit while a Stripe plan is paid (Team active/trialing; Enterprise active/trialing/past_due);
    • otherwise, for admin-set or grandfathered Team/Enterprise, max(seats, 5);
    • otherwise (Free), 5 + seatsFreeAdditional.
    • Only applies where the seat gate is on. No flag takes part.
  • Every join path uses it. getOrganizationSeatAddEligibility serves /v1/invitations, SSO JIT, verified-domain join and SCIM. A full admin-set plan gets 409 org_limit_reached; Free keeps the 402 Team flow.
  • Data lives in metadata.plan { tier, source, seats, endsAt, grantedAt }. There is no migration. limits.members is mirrored for one release. Saving Free writes seatsFreeAdditional; saving Team/Enterprise clears it. endsAt is display only.
  • APIs. PATCH …/plan and den_update_org_plan share setOrganizationPlanByAdmin, take endsAt, and return the resolved plan. The plan state was added to the admin org detail, the admin list rows and /v1/billing. /free-seats is kept, and /control-panel redirects to /billing.
  • Flags. enterprisePlan and organizationGrants are retired (RETIRED_FEATURE_KEYS, Helm synced). The Control panel and grants code are removed. The grant tables, schema and typeid are kept, so no data is dropped (0 grants in production). Sandbox Free Auto is now refused in both Den API and the Gateway when the sandbox row is closed or expired.

What else changed?

  • .spectral.yaml: OrganizationPlanState.endsAt is a calendar date, exempted from ow-timestamps-date-time.
  • Sandbox create no longer takes extraFreeAutoUsd.
  • readAdminOpenWorkWebAccess moved to its own module so the org detail can show OpenWork Web access.
  • docs/enterprise-plan-gating.md updated.

Deploy: ship Den API and the Gateway together. The Gateway's sandbox check replaces the benefit-setting check.

Pre-deploy ops step (not performed here): one grandfathered org (~32 users, joins via SSO) must get enough users in the new Billing tab first, or new SSO sign-ins are refused. List at-risk orgs read-only:

SELECT o.id, o.name, JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.plan.tier')) tier, JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.plan.source')) src,
  -- CAST: JSON values would otherwise compare as text ('40' < '5')
  GREATEST(5, CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.plan.seats')), JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.limits.members')), '5') AS UNSIGNED)) user_limit,
  (SELECT COUNT(*) FROM member m LEFT JOIN invitation i ON i.id = m.invite_id
    WHERE m.organization_id = o.id AND m.removed_at IS NULL AND m.is_setup_agent = 0
      AND (m.joined_at IS NOT NULL OR (i.status = 'pending' AND i.expires_at > NOW()))) users
FROM organization o
WHERE JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.plan.tier')) IN ('team','enterprise')
  AND JSON_UNQUOTE(JSON_EXTRACT(o.metadata,'$.plan.source')) IN ('manual','grandfathered')
  AND NOT EXISTS (SELECT 1 FROM org_subscriptions s WHERE s.organization_id = o.id
    AND ((s.type = 'seat' AND s.status IN ('active','trialing')) OR (s.type = 'enterprise' AND s.status IN ('active','trialing','past_due'))))
HAVING users >= user_limit;

DECISION NEEDED: a Free org with teamPlan off still sees today's legacy Free seats card (with its Upgrade to Team button) instead of the plan card. Team and Enterprise orgs always get the plan card. I chose the most conservative option, which means no change for Free orgs.

Release note

An organization's plan and allowed users are now set in one place, and an Enterprise or Team plan from OpenWork lets people join up to its users by invitation, SSO or SCIM.

Evidence

evals/proofs/organization-billing.proof.test.ts

  • Before: Acme is a Free row ("5 / 5") with no Manage panel.
  • After: the admin sets Enterprise / 20 / ends Nov 22. The header shows "Enterprise · set by admin, 5 of 20, Nov 22, 2026", and the list shows "5 / 20 · ends Nov 22". The owner's Billing shows "5 of 20 users · ends Nov 22, 2026". At the limit, the next invite shows "Your plan is full".
  • Also updated: admin-organizations-list, admin-access and sandbox-organizations proofs. API tests: enterprise-plan, sandbox-organizations, billing-plans.

Design: P1, P2, P3, P4, S1, S2, S3, C1, C5, V1

Screenshots (real size, from the proof run)

Billing tab, Enterprise trial
Billing tab, Free
Billing tab, paid in Stripe
Organizations list rows
Customer Billing plan card
Members: Your plan is full

One pure rule (organization-plan.ts) decides how many users an organization
may have, used by invitations, SSO, SCIM, /admin and /v1/billing. Admin-set
Team/Enterprise plans allow their seats with no feature flag; a full plan
answers org_limit_reached instead of the Team upgrade. Adds an optional
display-only plan end date, the /admin Billing tab, and retires the
enterprisePlan and organizationGrants flags (sandbox Free Auto now follows the
sandbox row).
@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
openwork-app Ready Ready Preview Oct 27, 2026 4:45am UTC
openwork-den Ready Ready Preview Oct 27, 2026 4:45am UTC
openwork-diagnostics Ready Ready Preview Oct 27, 2026 4:45am UTC
1 Skipped Deployment
Project Deployment Actions Updated
openwork-landing Ignored Ignored Preview Oct 27, 2026 4:45am UTC

Request Review

@benjaminshafii
benjaminshafii deployed to pr-internal-specs October 26, 2026 23:23 — with GitHub Actions Active
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

OpenWork evidence

Status Commit Preview Updated
🔴 Ready · evidence needs attention ea36cb5 Open preview 2026-10-27 04:53:25 UTC

Run details · Open the preview to inspect evidence and sandbox options.

diff-warden[bot]
diff-warden Bot previously approved these changes Oct 26, 2026

@diff-warden diff-warden Bot left a comment

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.

Warden clearance. Complete security and confidentiality review of 66ec9ab3becb3379a9f0220b11a7bdd2916fead4 found no high or medium security findings and no confidentiality findings. Any new push dismisses this approval. Analysis run

@diff-warden

diff-warden Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Warden: approved

No high or medium security findings and no confidentiality findings.

Reviewed ea36cb536e87 · Warden run · Updates after every Warden run.

@github-actions
github-actions Bot temporarily deployed to Evidence / PR 6516 October 26, 2026 23:31 Inactive
… grow past the Free limit

- evals/worlds/plan-shapes.ts seeds, through public Den APIs only, an
  Enterprise trial set to 20 users with 2 joined and 3 invited, Free at its
  5-user limit, an Enterprise plan for 6 users, and Team paid in the loopback
  test-mode Stripe. The same seed runs on dev and on this branch.
- `pnpm world up preview-den --stage <stage> --seed plans` boots Den locally
  with those organizations and prints what to try in each.
- The enterprise-trial-invites proof drives the owner's Members and Billing
  pages and the platform admin's Billing tab. On dev the sixth invitation on
  the trial opens "Upgrade to the Team plan"; on this branch it is sent.
@benjaminshafii

Copy link
Copy Markdown
Member Author

Seeded plan situations: before (dev) and after (this branch)

evals/worlds/plan-shapes.ts seeds generic organizations through public Den APIs, so the same seed runs on both. Proof: evals/proofs/enterprise-trial-invites.proof.test.ts (pnpm evals:e2e enterprise-trial-invites --local). By hand: pnpm world up preview-den --stage <stage> --seed plans.

Before, on dev (b7afa30): an Enterprise trial set to 20 users, with 2 joined and 3 invited. Inviting a sixth person opens "Upgrade to the Team plan".

dev: sixth invitation blocked

After, on this branch: the sixth person is invited.

branch: sixth invitation sent

The owner's Billing page shows Enterprise, 6 of 20 users and the end date:

branch: owner Billing

The platform admin's Billing tab shows the same plan:

branch: admin Billing tab

An Enterprise plan set to 6 users takes the sixth person and says "Your plan is full" at the seventh. Free at its limit still offers Team, and Team paid in Stripe has no limit (in the proof report).

branch: plan full

# Conflicts:
#	packaging/helm/openwork-ee/values.yaml

@diff-warden diff-warden Bot left a comment

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.

Warden clearance. Complete security and confidentiality review of ea36cb536e8754434c98c714cac6a2646f096f73 found no high or medium security findings and no confidentiality findings. Any new push dismisses this approval. Analysis run

@benjaminshafii
benjaminshafii marked this pull request as ready for review October 27, 2026 04:55
@benjaminshafii
benjaminshafii merged commit 12dc647 into dev Oct 27, 2026
46 of 49 checks passed

This branch was successfully deployed

6 active (1 outdated) deployments
Evidence / PR 6516 — ea36cb53 Deployed Oct 27, 2026 by github-actions[bot]
Preview – openwork-app — ea36cb53 Deployed Oct 27, 2026 by vercel[bot]
Preview – openwork-den — ea36cb53 Deployed Oct 27, 2026 by vercel[bot]
Preview – openwork-diagnostics — ea36cb53 Deployed Oct 27, 2026 by vercel[bot]
Preview – openwork-landing — 66ec9ab3 Deployed Oct 26, 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