Summary
On self-hosted Enterprise (licence-activated) instances, inviting any user beyond the first organisation admin fails, even though the licence grants more seats and the UI correctly shows them as available (e.g. "1 of 20 seats used").
Two different, misleading errors surface depending on the invite method, but both have the same root cause.
Affected versions
Present from v2.217.1 (first release containing #6663) through latest (v2.245.0). Confirmed by reading source at v2.233.0 and main, and reproduced on a self-hosted Enterprise instance running v2.233.0. Setting max_seats manually (see workaround) resolves it, confirming the mechanism.
Symptoms
A. "Invite members" (email invite): invitee registers, then accepting fails with:
The organisation you have been invited to has no seats available. Please contact the organisation administrator to resolve this before trying again.
B. "Copy Invite Link" (shareable link): invitee registers, then sees:
We could not validate your invite, please check the invite URL and email address you have entered is correct.
In both cases the org shows e.g. "1 of 20 seats" used and the licence grants 20.
Root cause
Organisation.over_plan_seats_limit() only consults subscription metadata (which correctly reads the licence) when has_paid_subscription() is true — i.e. when a subscription_id is set. A licence-activated Enterprise org has no subscription_id (that's a Chargebee/SaaS field), so it falls through to the raw Subscription.max_seats DB column, which is never populated from the licence and defaults to MAX_SEATS_IN_FREE_PLAN = 1.
# api/organisations/models.py
def over_plan_seats_limit(self, additional_seats=0):
if self.has_paid_subscription(): # bool(subscription_id) -> False for licence-only enterprise
return self.num_seats + additional_seats > self.subscription.get_subscription_metadata().seats
# licence-only enterprise lands here:
return self.num_seats + additional_seats > getattr(self.subscription, "max_seats", MAX_SEATS_IN_FREE_PLAN)
# ^ defaults to 1, never set from the licence
This contradicts the display and invite-creation paths, which both go through get_subscription_metadata() → _get_subscription_metadata_for_self_hosted() → licence.get_licence_information().num_seats (= 20). So:
- Admin can create the invite (creation check in
InviteViewSet.perform_create uses metadata = 20) ✅
- UI shows "1 of 20" (serializer reads metadata = 20) ✅
- Invitee cannot accept (
join_organisation_from_invite → over_plan_seats_limit uses max_seats = 1) ❌
1 existing + 1 joining = 2 > 1 → SubscriptionDoesNotSupportSeatUpgrade is raised on every join past the first user.
The two broken branches are marked # pragma: no cover, and the e2e Enterprise fixture (api/e2etests/e2e_seed_data.py) seeds subscription_id="test_subscription_id", which routes through the paid branch and masks the bug in tests.
Why the invite-link error is different (and misleading)
acceptInvite() in frontend/common/stores/account-store.js posts to the invite-link endpoint first, and on any error falls back to the email-invite endpoint unless the verify_seats_limit_for_invite_links flag is enabled:
data.post(`${Project.api}users/join/link/${id}/`)
.catch((error) => {
if (Utils.getFlagsmithHasFeature('verify_seats_limit_for_invite_links') && error.status === 400) {
API.ajaxHandler(store, error); throw error // surface the real seat error
}
return data.post(`${Project.api}users/join/${id}/`) // else retry against EMAIL endpoint
})
When the flag is off (default on self-hosted), the genuine 400 seat error from users/join/link/<hash>/ is swallowed and the link hash is retried against the email-invite endpoint users/join/<hash>/. That hash doesn't exist in the Invite table → 404 ("No Invite matches the given query") → the misleading "could not validate your invite" message. The real problem is still the seat limit.
Steps to reproduce
- Self-hosted Enterprise build, org activated via licence file with
num_seats > 1 (e.g. 20), no Chargebee subscription_id.
- Confirm
organisations_subscription.max_seats is still its default of 1.
- Org has 1 member (the admin). UI shows "1 of 20 seats".
- Invite a second user via "Invite members" → invitee registers → accept fails with "no seats available".
- Invite via "Copy Invite Link" → invitee registers → fails with "could not validate your invite".
Expected
A licence-activated Enterprise org should allow members to join up to the licence's num_seats. Seat enforcement should agree with what the UI and invite-creation flow display.
Actual
Enforcement uses Subscription.max_seats (= 1), blocking all joins past the first user, regardless of the licence seat count.
Suggested fix (for maintainers to choose)
- Make
over_plan_seats_limit() consult get_subscription_metadata().seats for self-hosted Enterprise regardless of subscription_id, or
- Populate
Subscription.max_seats from licence.num_seats when a licence is imported/applied.
Either also removes the need for the silent email-endpoint fallback to mask seat errors on the invite-link path.
Workaround (confirmed)
Set organisations_subscription.max_seats to >= the licence seat count (via Django admin or directly in Postgres). This resolves both symptoms immediately.
Summary
On self-hosted Enterprise (licence-activated) instances, inviting any user beyond the first organisation admin fails, even though the licence grants more seats and the UI correctly shows them as available (e.g. "1 of 20 seats used").
Two different, misleading errors surface depending on the invite method, but both have the same root cause.
Affected versions
Present from v2.217.1 (first release containing #6663) through latest (v2.245.0). Confirmed by reading source at
v2.233.0andmain, and reproduced on a self-hosted Enterprise instance running v2.233.0. Settingmax_seatsmanually (see workaround) resolves it, confirming the mechanism.Symptoms
A. "Invite members" (email invite): invitee registers, then accepting fails with:
B. "Copy Invite Link" (shareable link): invitee registers, then sees:
In both cases the org shows e.g. "1 of 20 seats" used and the licence grants 20.
Root cause
Organisation.over_plan_seats_limit()only consults subscription metadata (which correctly reads the licence) whenhas_paid_subscription()is true — i.e. when asubscription_idis set. A licence-activated Enterprise org has nosubscription_id(that's a Chargebee/SaaS field), so it falls through to the rawSubscription.max_seatsDB column, which is never populated from the licence and defaults toMAX_SEATS_IN_FREE_PLAN = 1.This contradicts the display and invite-creation paths, which both go through
get_subscription_metadata()→_get_subscription_metadata_for_self_hosted()→licence.get_licence_information().num_seats(= 20). So:InviteViewSet.perform_createuses metadata = 20) ✅join_organisation_from_invite→over_plan_seats_limitusesmax_seats= 1) ❌1 existing + 1 joining = 2 > 1→SubscriptionDoesNotSupportSeatUpgradeis raised on every join past the first user.The two broken branches are marked
# pragma: no cover, and the e2e Enterprise fixture (api/e2etests/e2e_seed_data.py) seedssubscription_id="test_subscription_id", which routes through the paid branch and masks the bug in tests.Why the invite-link error is different (and misleading)
acceptInvite()infrontend/common/stores/account-store.jsposts to the invite-link endpoint first, and on any error falls back to the email-invite endpoint unless theverify_seats_limit_for_invite_linksflag is enabled:When the flag is off (default on self-hosted), the genuine 400 seat error from
users/join/link/<hash>/is swallowed and the link hash is retried against the email-invite endpointusers/join/<hash>/. That hash doesn't exist in theInvitetable → 404 ("No Invite matches the given query") → the misleading "could not validate your invite" message. The real problem is still the seat limit.Steps to reproduce
num_seats> 1 (e.g. 20), no Chargebeesubscription_id.organisations_subscription.max_seatsis still its default of 1.Expected
A licence-activated Enterprise org should allow members to join up to the licence's
num_seats. Seat enforcement should agree with what the UI and invite-creation flow display.Actual
Enforcement uses
Subscription.max_seats(= 1), blocking all joins past the first user, regardless of the licence seat count.Suggested fix (for maintainers to choose)
over_plan_seats_limit()consultget_subscription_metadata().seatsfor self-hosted Enterprise regardless ofsubscription_id, orSubscription.max_seatsfromlicence.num_seatswhen a licence is imported/applied.Either also removes the need for the silent email-endpoint fallback to mask seat errors on the invite-link path.
Workaround (confirmed)
Set
organisations_subscription.max_seatsto >= the licence seat count (via Django admin or directly in Postgres). This resolves both symptoms immediately.