Skip to content

feat(tenancy): a project ceiling per plan, and copy for the backend's error codes - #55

Merged
basb7 merged 4 commits into
mainfrom
feat/account-plans-and-quotas
Sep 4, 2026
Merged

feat(tenancy): a project ceiling per plan, and copy for the backend's error codes#55
basb7 merged 4 commits into
mainfrom
feat/account-plans-and-quotas

Conversation

@basb7

@basb7 basb7 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Seats already had a plan ceiling. This adds projects, stops there, and fixes the frontend gap that made the addition visible.

Every ceiling is scoped to an organization

That is the whole shape: an organization is where a subscription lives, so anything a plan limits is something an organization contains. Organization.plan answers all of it and nothing new has to hold a subscription.

_LIMITS = {
    Plan.COMMUNITY: {"seats": None, "projects": None},
    Plan.FREE:      {"seats": 3,    "projects": 1},
    Plan.STARTER:   {"seats": 5,    "projects": 10},
    Plan.TEAM:      {"seats": 25,   "projects": None},
}

How many organizations somebody creates is deliberately not limited. An empty one holds no projects, no flags and no traffic, so metering them would be metering nothing.

And the moment you do limit them, the model needs an owner to count against — Organization has none, and an ADMIN membership is not a substitute: being invited to administer somebody else's organization would consume your own allowance. An earlier revision of this branch had an Account model and that ceiling; it was removed once the ownership question surfaced. Flagsmith does not limit organizations either.

Flags, environments, conditions and rules have no entry, on purpose. A limit that does not exist is not written down as None in twenty places — it simply has no check.

The check

Shares a transaction with the insert and locks the organization row first, the same shape as the seat check on invitation accept. Without it two concurrent creates both observe "one below the limit" and both commit.

SQLite ignores select_for_update silently, so that protection is only real on PostgreSQL — which is what CI and any deployment run. Said in the code so nobody reads a green local suite as proof of it.

The plan is never accepted from the request: the serializer already marks it read-only and the model defaults it, so upgrading cannot be a field in a POST body.

One setting is the whole difference between the editions

unset                           COMMUNITY — projects and seats unlimited
DEFAULT_ORGANIZATION_PLAN=FREE  1 project, 3 seats

Declared in both compose files with the override shape compose.yml already uses, so the default is visible without reading Python:

DEFAULT_ORGANIZATION_PLAN=${DEFAULT_ORGANIZATION_PLAN:-COMMUNITY}

A literal FREE in compose.dev.yml was tried and removed: it would have limited every development environment to one project, which is the opposite of what a self-hosted install is for.

An unrecognised value raises ImproperlyConfigured naming the four it accepts, rather than falling through to something.

The frontend gap this exposed

Endpoints that distinguish their failures answer with a machine code, and api.ts surfaces it as the error's message — on purpose, since copy should not be a backend deploy. But the translation was one switch in the invitation page; everywhere else showed the code itself, and nothing failed when a code had none.

So project_limit_reached shipped in the first commit with no copy, and a person hitting the ceiling would have read exactly that.

ERROR_COPY is now one table and errorCopy() reads it. An unmapped code is returned as it arrived rather than replaced with something generic: a "please try again" throws away the only information anybody had, and the person most likely to see a raw code is whoever just added it. A screen with a better meaning of its own passes a fallback — the invitation page still says the link is dead, because there it is.

Kept complete by a test that walks the backend's Python for {"error": "..."} and fails naming any code with no copy, and any copy for a code the backend cannot send. Verified it discriminates: removing one entry fails the run and names it.

Wired into the four screens that receive these codes: project creation, member removal, invitation accept, password reset.

Verified

Backend 603 passed on SQLite, 603 on PostgreSQL 18
Frontend 108 passed, lint clean, build clean
Compose docker compose config resolves to COMMUNITY unset and FREE overridden, in both files

… organizations

Seats already had a plan ceiling. This adds projects, and stops there.

Every ceiling is scoped to an organization, because an organization is where a
subscription lives. That is the whole shape: anything a plan limits is
something an organization contains, so Organization.plan answers all of it and
nothing new has to hold a subscription.

How many organizations somebody creates is deliberately not limited. An empty
one holds no projects, no flags and no traffic, so metering them would be
metering nothing -- and the moment you do limit them, the model needs an owner
to count against, because "how many do you own" cannot be answered by an ADMIN
membership: being invited to administer somebody else's organization would
consume your own allowance. Flagsmith does not limit organizations either, and
the ownership question is the reason worth naming.

_SEAT_LIMITS becomes _LIMITS, a row per plan. Flags, environments, conditions
and rules have no entry, on purpose: a limit that does not exist is not written
down as None in twenty places, it simply has no check.

The project check shares a transaction with the insert and locks the
organization row first, the same shape as the seat check on invitation accept.
Without it two concurrent creates both observe "one below the limit" and both
commit. SQLite ignores select_for_update silently, so that protection is only
real on PostgreSQL -- which is what CI and any deployment run.

The plan is never accepted from the request. The serializer already marks it
read-only and the model defaults it, so upgrading cannot be a field in a POST
body.

DEFAULT_ORGANIZATION_PLAN is the whole difference between the editions:

    unset                           COMMUNITY -- projects and seats unlimited
    DEFAULT_ORGANIZATION_PLAN=FREE  1 project, 3 seats

Verified both, and that an unrecognised value raises ImproperlyConfigured
naming the four it accepts rather than falling through to something.

11 tests: the ceiling and the refusal, that it is per organization rather than
per user, COMMUNITY unaffected, organizations staying unlimited, administering
somebody else's costing nothing, the plan coming from the setting, the plan not
being choosable by the caller, an unrecognised default refused, and flags
staying unlimited.

603 passed on SQLite and 603 on PostgreSQL 18.
Endpoints that distinguish their failures answer with a machine code --
{"error": "seat_limit_reached"} -- and api.ts surfaces that code as the
ApiError's message, on purpose: copy should not be a backend deploy, and the
same failure reaches more than one screen.

The translation was optional and scattered. One switch in the invitation page
covered four codes; everywhere else showed the code itself. Nothing failed when
a code had no copy, so project_limit_reached shipped in the previous commit
with none, and a person hitting a plan ceiling would have read exactly that.

ERROR_COPY is one table, and errorCopy() reads it. An unmapped code is
returned as it arrived rather than replaced with something generic: a "please
try again" throws away the only piece of information anybody had, and the
person most likely to see a raw code is whoever just added it. A screen where
an unrecognised failure means something better of its own passes a fallback --
the invitation page still says the link is dead, because on that page it is.

The table is kept complete by a test that walks the backend's Python for
`{"error": "..."}` and fails naming any code with no copy, and any copy for a
code the backend cannot send. Verified it discriminates: removing one entry
fails the run and names it. Without that, this table drifts the same way the
switch did -- silently, because an unmapped code still looks like a message.

Wired into the four screens that receive these codes: project creation, member
removal, invitation accept, and password reset.
DEFAULT_ORGANIZATION_PLAN was readable from settings but not visible to anyone
reading a compose file, so the value a container actually runs on could only be
found by reading Python.

Declared with the same override shape compose.yml already uses for everything
configurable -- ${VAR:-default} -- so the file states the default and an
operator can change it without editing the file:

    DEFAULT_ORGANIZATION_PLAN=${DEFAULT_ORGANIZATION_PLAN:-COMMUNITY}

COMMUNITY in both, including the production file. A hosted deployment sets FREE
in its own environment; somebody who clones this repository and runs it gets
what a self-hosted install should get, which is no ceilings at all. The
alternative -- a literal FREE in compose.dev.yml -- was tried and removed: it
would have limited every development environment to one project by default,
which is the opposite of what an open source install is for.

Verified through `docker compose config`, which resolves the interpolation:
COMMUNITY with nothing set, FREE with the variable exported, in both files.
ruff check failed on CI. makemigrations writes `import tenancy.models` above
`from django.db import ...`, which is the opposite of this project's order --
Django generates the file and does not know the rule.

Caught by CI rather than locally because the backend lint was never run here:
the frontend one was, the backend one was not. `ruff check .` is what the CI
job runs, and it is one command.
@basb7
basb7 merged commit 24a042d into main Sep 4, 2026
3 checks passed
@basb7
basb7 deleted the feat/account-plans-and-quotas branch September 4, 2026 01:47
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