Seat Enforcement
Loquent enforces a strict invariant: members + pending invitations ≤ seat count. This prevents organizations from exceeding their paid seat allocation through invitations or concurrent accepts.
Seat usage model
Section titled “Seat usage model”The SeatUsage struct (src/mods/billing/types/seat_usage_type.rs) is the single source of truth for seat consumption:
pub struct SeatUsage { pub seat_count: i32, // from organization_subscription pub members_count: i32, // active members in the org pub pending_invitations_count: i32, // outstanding invitations pub seats_used: i32, // members + pending pub seats_available: i32, // seat_count - seats_used (min 0) pub at_capacity: bool, // seats_used >= seat_count}Pending invitations consume a seat to prevent send-now-fail-later surprises. The get_seat_usage service (src/mods/billing/services/get_seat_usage_service.rs) computes this from three aggregate queries and is generic over ConnectionTrait, so it works both in normal requests and inside transactions.
API endpoint
Section titled “API endpoint”GET /api/billing/seat-usage returns the current org’s SeatUsage. Every member can read it (informational only — mutations remain owner-gated).
Send-invite gate
Section titled “Send-invite gate”When an owner sends an invitation, the send_invitation_api (src/mods/invitation/api/send_invitation_api.rs) calls get_seat_usage as a pre-flight check. If at_capacity is true, it returns:
SendInvitationResponse::NoSeatsAvailable { seats_used: i32, seat_count: i32,}The UI displays a toast with the usage numbers and replaces the invite form with a capacity notice.
Accept-invite gate (TOCTOU-safe)
Section titled “Accept-invite gate (TOCTOU-safe)”The accept flow (src/mods/invitation/api/accept_invitation_api.rs) wraps the entire operation in a single transaction with lock_exclusive() on the organization_subscription row. This prevents two concurrent accepts from both succeeding on the last available seat.
Accept request arrives → BEGIN transaction → SELECT ... FOR UPDATE on organization_subscription → Re-compute get_seat_usage inside the txn → If at_capacity → rollback, return NoSeatsAvailable → Otherwise: create user, create member, assign roles, delete invitation → COMMITThe new-user flow allocates the user_id inside the transaction, so a failed seat check leaves no partial user record. The refactored auth helpers (create_user, create_member, create_session, assign_roles_to_member) accept &impl ConnectionTrait to compose inside the transaction.
Bootstrap window
Section titled “Bootstrap window”If an org has no organization_subscription row yet (the Stripe webhook hasn’t landed after signup), seat_count defaults to 0 and the org is reported as at capacity. This is safe because the bootstrap owner is created directly during signup — not through the invitation flow.
Team members UI
Section titled “Team members UI”The team members page (src/mods/settings/components/pages/team_members_page.rs) shows a seat usage banner:
- Under capacity: “X of Y seats used” with a green “Available” badge. The invite form is visible.
- At capacity: Red “At capacity” badge. The invite form is hidden entirely and replaced by a “Capacity reached” notice with an “Upgrade plan” link pointing to
/settings/billing.
Revoking a pending invitation immediately restores headroom — the banner counter drops and the invite form reappears.
Admin seat reconciliation
Section titled “Admin seat reconciliation”The admin Overview tab includes a “Seat reconciliation” card (src/mods/admin/components/admin_seat_reconciliation_card_component.rs) for orgs that drifted over capacity before enforcement was added.
Preview
Section titled “Preview”GET /api/admin/seats/reconciliations (super-admin only) lists every org where members + pending > seat_count. Each row is an OrgSeatReconciliation:
pub struct OrgSeatReconciliation { pub org_id: String, pub org_name: String, pub current_seat_count: i32, pub members_count: i32, pub pending_invitations_count: i32, pub target_seat_count: i32, // members + pending pub has_stripe_subscription: bool, // raw Stripe ID not exposed}Orgs with no Stripe subscription show a “No Stripe sub” placeholder (Apply is hidden — these need the signup webhook to land first, tracked in issue #1169).
POST /api/admin/seats/reconcile with an org_id body parameter:
- Reads the Stripe subscription ID outside the transaction (avoids holding a row lock during network I/O).
- Pushes the corrective
quantityto Stripe viaSubscription::updateon the first subscription item. - Opens a transaction with
lock_exclusive()onorganization_subscription. - Re-reads seat usage under lock and mirrors the value to
seat_count. - Records an admin audit entry (
seats.reconcile).
The inbound Stripe webhook idempotently confirms the same value via upsert_subscription.
Security hardening
Section titled “Security hardening”- Raw database error messages no longer leak to clients on the public
accept_invitationendpoint — they are logged server-side and an opaque “internal error” is returned. - The raw Stripe subscription ID is not exposed in the admin preview API; only
has_stripe_subscription: boolis surfaced.
Key files
Section titled “Key files”| File | Purpose |
|---|---|
src/mods/billing/types/seat_usage_type.rs | SeatUsage struct and constructor |
src/mods/billing/services/get_seat_usage_service.rs | Seat usage query (generic over ConnectionTrait) |
src/mods/billing/api/get_seat_usage_api.rs | GET /api/billing/seat-usage |
src/mods/invitation/api/send_invitation_api.rs | Pre-flight capacity check on send |
src/mods/invitation/api/accept_invitation_api.rs | TOCTOU-safe accept with FOR UPDATE |
src/mods/admin/services/admin_reconcile_seats_service.rs | Preview + apply reconciliation |
src/mods/admin/types/admin_seat_reconciliation_types.rs | OrgSeatReconciliation type |
src/mods/settings/components/pages/team_members_page.rs | Seat usage banner UI |
src/shared/types/invitation_response_type.rs | SendInvitationResponse / AcceptInvitationResponse enums |