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:
2026-05-28 12:49:59 -04:00
parent 02fc47c832
commit 47ff8ad2b5
6 changed files with 467 additions and 2 deletions

View File

@@ -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(