feat(l1): enforce seat limits on invite, accept-invite, role-change
For engineer + l1_tech roles, check_seat_available is called at each
mutation point. Returns 402 Payment Required with structured detail
{code: 'seat_limit_exceeded', role, current, limit, upgrade_url} when
seats are full. Grandfathering: existing over-seated accounts keep
existing users; only new mutations are blocked.
Also updates AccountInviteCreate and AccountRoleUpdate schemas to
accept l1_tech as a valid role value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,29 @@ async def _sign_in_or_register(
|
||||
|
||||
if is_new_user:
|
||||
if invite_record is not None:
|
||||
# Seat enforcement: re-check at OAuth accept time (race-condition guard).
|
||||
if invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
sub = await get_account_subscription(invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(Account).where(Account.id == invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, invite_record.role, db)
|
||||
if not seat_result.available:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "seat_limit_exceeded",
|
||||
"role": seat_result.role,
|
||||
"current": seat_result.current,
|
||||
"limit": seat_result.limit,
|
||||
"upgrade_url": "/account/billing",
|
||||
},
|
||||
)
|
||||
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
|
||||
Reference in New Issue
Block a user