Skip to content

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.

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.

GET /api/billing/seat-usage returns the current org’s SeatUsage. Every member can read it (informational only — mutations remain owner-gated).

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.

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
→ COMMIT

The 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.

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.

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.

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.

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:

  1. Reads the Stripe subscription ID outside the transaction (avoids holding a row lock during network I/O).
  2. Pushes the corrective quantity to Stripe via Subscription::update on the first subscription item.
  3. Opens a transaction with lock_exclusive() on organization_subscription.
  4. Re-reads seat usage under lock and mirrors the value to seat_count.
  5. Records an admin audit entry (seats.reconcile).

The inbound Stripe webhook idempotently confirms the same value via upsert_subscription.

  • Raw database error messages no longer leak to clients on the public accept_invitation endpoint — 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: bool is surfaced.
FilePurpose
src/mods/billing/types/seat_usage_type.rsSeatUsage struct and constructor
src/mods/billing/services/get_seat_usage_service.rsSeat usage query (generic over ConnectionTrait)
src/mods/billing/api/get_seat_usage_api.rsGET /api/billing/seat-usage
src/mods/invitation/api/send_invitation_api.rsPre-flight capacity check on send
src/mods/invitation/api/accept_invitation_api.rsTOCTOU-safe accept with FOR UPDATE
src/mods/admin/services/admin_reconcile_seats_service.rsPreview + apply reconciliation
src/mods/admin/types/admin_seat_reconciliation_types.rsOrgSeatReconciliation type
src/mods/settings/components/pages/team_members_page.rsSeat usage banner UI
src/shared/types/invitation_response_type.rsSendInvitationResponse / AcceptInvitationResponse enums