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:
@@ -289,6 +289,33 @@ async def register(
|
||||
detail="Invite code has expired"
|
||||
)
|
||||
|
||||
# Seat enforcement: re-check at accept time (race-condition guard).
|
||||
# Fires only when an account invite is being accepted and the target role
|
||||
# is seat-counted (engineer, l1_tech). Accounts without a subscription
|
||||
# (free / pre-billing) are not blocked.
|
||||
if account_invite_record and account_invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
from app.models.account import Account as _Account
|
||||
sub = await get_account_subscription(account_invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(_Account).where(_Account.id == account_invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, account_invite_record.role, db)
|
||||
if not seat_result.available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "seat_limit_exceeded",
|
||||
"role": seat_result.role,
|
||||
"current": seat_result.current,
|
||||
"limit": seat_result.limit,
|
||||
"upgrade_url": "/account/billing",
|
||||
},
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
Reference in New Issue
Block a user